PartyLayer
Try Demo

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. Depending on the registry and receiver preapproval, the result can settle directly or create a TransferInstruction awaiting further steps.

⚠️ Warning
Common mistake: exercising Amulet_Transfer directly on #splice-amulet:Splice.Amulet:Amulet is the wrong contract/choice combination for the Token Standard factory flow. A legacy command cannot be assumed valid for your deployed package. Use the deployed Token Standard interface and the registry's current factory/context.

The Token Standard transfer flow

Canton Improvement Proposal CIP-0056 supports direct, self and offer transfer workflows. An offer commonly uses these two steps:

  • Sender exercises TransferFactory_Transfer by interface on a TransferFactory contract. The factory validates the request and creates a TransferInstruction.
  • Recipient exercises TransferInstruction_Accept on 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-factory endpoint of the DSO's Scan (it returns the factoryId plus a choiceContext.choiceContextData blob to pass as extraArgs.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:Holding interfaceId.
  • Recipient partyId and the instrument details ( admin = DSO party, id = "Amulet").

React (interface-based TransferFactory_Transfer)

tsx
import { useAccount, useTransactionFlow } from '@partylayer/react';
import type { TokenDisclosedContract } from '@partylayer/react/query';

const TRANSFER_FACTORY_INTERFACE =
  '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory';

function TransferButton({
  factoryCid,
  choiceContext,
  disclosedContracts,
  inputHoldingCids,
  dsoParty,
  receiverPartyId,
}: {
  factoryCid: string;             // factoryId from Scan transfer-factory
  choiceContext: Record<string, unknown>; // raw choiceContext.choiceContextData from Scan
  disclosedContracts: TokenDisclosedContract[]; // from that SAME fresh choice context
  inputHoldingCids: string[];     // from your ACS query
  dsoParty: string;               // instrument admin (e.g. DSO on the network)
  receiverPartyId: string;
}) {
  const { isConnected, party } = useAccount();
  const flow = useTransactionFlow(); // add an independent confirm reader; see Transaction lifecycle

  const handleTransfer = async () => {
    if (!isConnected || !party) return;

    const nowIso = new Date().toISOString();
    const expiresIso = new Date(Date.now() + 5 * 60_000).toISOString();

    // v2 JSON Ledger API: interface exercises use the standard ExerciseCommand
    // wrapper with the interfaceId placed in the templateId field.
    const payload = {
      commands: [
        {
          ExerciseCommand: {
            templateId: TRANSFER_FACTORY_INTERFACE,
            contractId: factoryCid,
            choice: 'TransferFactory_Transfer',
            choiceArgument: {
              expectedAdmin: dsoParty,
              transfer: {
                sender: party,
                receiver: receiverPartyId,
                amount: '10.0',
                instrumentId: { admin: dsoParty, id: 'Amulet' },
                lock: null,
                requestedAt: nowIso,
                executeBefore: expiresIso,
                inputHoldingCids,
                meta: { values: {} },
              },
              // extraArgs.context is the ChoiceContext blob from the Scan
              // transfer-factory response, passed through unchanged. The
              // canonical Splice CLI assigns it verbatim, with NO extra wrapping:
              //   extraArgs.context = transferFactory.choiceContext.choiceContextData
              // choiceContext below already IS that choiceContextData object.
              extraArgs: {
                context: choiceContext,
                meta: { values: {} },
              },
            },
          },
        },
      ],
      commandId: crypto.randomUUID(),
      applicationId: 'my-app',
      actAs: [party],
      readAs: [],
      disclosedContracts,
    };

    // Wallet-owned prepare/approve/sign/execute. Exactly one submission.
    const result = await flow.submitTransaction({ signedTx: payload }, { commandId: payload.commandId });
    console.log(result.status, result.receipt); // submitted is NOT settled
  };

  if (!isConnected) return null;

  return (
    <>
      <button onClick={() => { void handleTransfer().catch(console.error); }} disabled={flow.isPending}>
        {flow.isPending ? 'Awaiting wallet…' : 'Transfer 10 Amulet'}
      </button>
      {flow.transaction?.error && <p>Error: {flow.transaction.error.message}</p>}
    </>
  );
}
⚠️ Warning
Preserve the wallet approval path. Submit through the supported wallet action; native CIP-0103 connections use prepareExecute or the negotiated wait extension. Do not substitute a raw ledger write or sign an arbitrary transaction hash. The factory/context must be prepared for this exact party, network, amount, receiver and expiry; discard it when any of those change. Include its disclosures and synchronizer data. Independently confirm the ledger update, then inspect the transfer result: a committed instruction can still require receiver acceptance. See Transaction lifecycle.
ℹ️ Note
Where do 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.

Wallet-owned transfer convenience

PartyLayer also exposes a typed useTransfer / client.transferaction for wallets that declare the transfer capability. The wallet then selects holdings, constructs the transfer, displays approval and returns a real update id. This convenience is capability-dependent; it is not a universal CIP-0103 method. The ordinary PartyLayer installation already includes supported wallet runtimes.

For application-owned Token Standard workflows, retain the factory command route above. A typed hook supplies the cache and mutation lifecycle; the registry still supplies current choice context and disclosures. Refresh spent contract ids and expired context before a new intent.

💡 Tip
Check the connected wallet's capabilities and supported network. A successful message signature is not proof that a transfer works. Never retry an unknown submission to obtain a better receipt; reconcile the original command or business intent first.

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:

typescript
const TRANSFER_INSTRUCTION_INTERFACE =
  '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction';

await client.submitTransaction({
  signedTx: {
    commands: [{
      ExerciseCommand: {
        templateId: TRANSFER_INSTRUCTION_INTERFACE, // interfaceId goes in templateId
        contractId: instructionCid,                 // from the recipient's ACS
        choice: 'TransferInstruction_Accept',
        choiceArgument: {
          extraArgs: {
            context: acceptChoiceContext, // raw choiceContextData from Scan, passed through unchanged
            meta: { values: {} },
          },
        },
      },
    }],
    commandId: crypto.randomUUID(),
    applicationId: 'my-app',
    actAs: [recipientPartyId],
    readAs: [],
    disclosedContracts: acceptDisclosedContracts, // from the fresh ACCEPT context
  },
});

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

Use a fully-qualified identifier: deployed package id, module and entity. A package-name reference such as #splice-api-token-transfer-instruction-v1:... also requires support from the selected wallet and participant. Do not pass an unqualified module/entity or a guessed package hash.

commandId uniqueness

Generate one commandId for each new business intent and retain it across lost responses and reload recovery. Do not generate a new id to retry an unknown write. Ledger deduplication has a submission identity and configured window; it does not replace application intent tracking. Never reuse an old id for a different operation.

Error handling

typescript
import {
  UserRejectedError,
  SessionExpiredError,
  CapabilityNotSupportedError,
} from '@partylayer/sdk';

try {
  const result = await client.submitTransaction({ signedTx: payload });
  // A returned acknowledgement still needs independent ledger confirmation.
} catch (err) {
  if (err instanceof UserRejectedError) {
    // Show rejection. A new attempt requires another explicit user action.
  } else if (err instanceof SessionExpiredError) {
    // Offer reconnect. Reconcile any issued write before a new submission.
  } else if (err instanceof CapabilityNotSupportedError) {
    // Explain that this operation is unavailable for the selected wallet.
  } else {
    // Preserve unknown outcome. Do not automatically send the payload again.
  }
}

References

PreviousWallet BalancesNextTransaction Lifecycle