Token Transfers
Transferring Amulet (or any CIP-56 token) on Canton goes through the Token Standard: you exercise a factory choice by interface, not a choice on the holding contract itself. The wallet prompts the user, the factory produces a TransferInstruction contract, and the recipient accepts it.
Amulet_Transfer directly on #splice-amulet:Splice.Amulet:Amulet is the legacy (pre-Token Standard) path. On today's Canton the ledger rejects it as an unknown choice and Loop's UI shows Execute Unknown on Unknown. Use the Token Standard flow described below.The Token Standard transfer flow
Canton Improvement Proposal CIP-56 defines a two-step transfer:
- Sender exercises
TransferFactory_Transferby interface on aTransferFactorycontract. The factory validates the request and creates aTransferInstruction. - Recipient exercises
TransferInstruction_Accepton the instruction contract. The ledger settles the transfer — input holdings are burned, output holdings are minted.
The sender does not exercise a choice on their own Splice.Amulet:Amulet contract — under CIP-56 the Amulet is a Holding (data only), and transfer logic lives on the factory.
Prerequisites
- Wallet connected — see Quick Start
- A TransferFactory contractId, fetched from the app-provider's Scan token-standard registry API. For Amulet, that is the
POST /registry/transfer-instruction/v1/transfer-factoryendpoint of the DSO's Scan (it returns thefactoryIdplus achoiceContext.choiceContextDatablob to pass asextraArgs.context). - One or more input holding contractIds — fetched from your own ACS (see Wallet Balances) using the
#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:HoldinginterfaceId. - Recipient partyId and the instrument details (
admin= DSO party,id="Amulet").
React (interface-based TransferFactory_Transfer)
ledgerApi call to /v2/commands/submit-and-wait above works when the connected wallet proxies the JSON Ledger API for a party that can submit directly to a participant node. External or passkey parties (for example Send) do not submit raw ledger commands; the wallet prepares, signs, and submits in one step. For those wallets, use PartyLayer's submitTransaction(), which routes the command set through the wallet's prepareExecute / prepareExecuteAndWait (the generic announce bridge maps submitTransaction to prepareExecute). Build the same TransferFactory_Transfer command and submit it through the path your target wallet supports.factoryCid and choiceContext come from? From the Splice Scan token-standard registry API that the registry issuer runs. You call POST /registry/transfer-instruction/v1/transfer-factory with the transfer choiceArguments in the body, and it returns { factoryId, transferKind, choiceContext: { choiceContextData, disclosedContracts } }. Use factoryId as the factory contractId and pass choiceContext.choiceContextData through as extraArgs.context. The path is the canonical spec route; a given deployment may front it under a Scan proxy prefix (for example /api/scan/v0). This is dApp-side off-ledger coordination, so PartyLayer does not abstract it.Loop wallet — convenience helper
Loop's own SDK ships a loop.wallet.transfer() helper that handles the Scan lookup, factory resolution, and submission for you. If your dApp targets Loop specifically, this is the simplest path. Access the underlying Loop SDK via the @fivenorth/loop-sdk package alongside PartyLayer:
Under the helper, Loop exposes the lower-level provider.submitTransaction(command, options) (returns first, with the ledger update arriving via onTransactionUpdate) and the opt-in provider.submitAndWaitForTransaction(command, options), which waits for the final result. Both accept the same Token Standard ExerciseCommand payload shown above.
prepareExecuteAndWait internally, so the Token Standard command flow is the canonical path there.Recipient accepts the instruction
After the sender's transaction commits, a TransferInstruction contract appears in the recipient's ACS. The recipient's dApp (or wallet UI) then exercises TransferInstruction_Accept on it:
Other instruction choices: TransferInstruction_Reject, TransferInstruction_Withdraw (sender cancels before accept), TransferInstruction_Update.
Troubleshooting
Loop UI shows "Execute Unknown on Unknown"
You're sending the legacy Amulet_Transfer command exercised directly on Splice.Amulet:Amulet. Loop's UI (and Canton since CIP-56) does not recognize this command. Switch to TransferFactory_Transfer on the TransferFactory interface as shown above.
"Loop Wallet submitAndWaitForTransaction resolved with an empty response"
The popup closed before you confirmed, or the wallet server returned an empty frame. Confirm in the wallet UI and retry. If it persists, double-check the interfaceId has the fully-qualified #package:Module:Interface form and that actAs matches the active party (from useAccount()).
Template / interface ID format
Loop requires the fully-qualified Daml form with the package-name prefix — #splice-amulet:... or #splice-api-token-transfer-instruction-v1:..., not the short Canton form. Our adapter surfaces the short-form mistake as a clear error pointing at this fix.
commandId uniqueness
Always generate a fresh commandId per submission (for example crypto.randomUUID()). The ledger deduplicates on commandId, so reusing one silently drops the second submission.
Error handling
References
- hyperledger-labs/splice → token-standard — Daml sources for
TransferFactory,TransferInstruction, andHolding. - CIP-56 / CIP-78 — Canton Token Standard
- Splice docs → Token Standard APIs
- Loop SDK →
wallet.transfer()helper