PartyLayerDocs
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, the factory produces a TransferInstruction contract, and the recipient accepts it.

⚠️ Warning
Common mistake: exercising 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_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, useLedgerApi } from '@partylayer/react';

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

function TransferButton({
  factoryCid,
  choiceContext,
  inputHoldingCids,
  dsoParty,
  receiverPartyId,
}: {
  factoryCid: string;             // factoryId from Scan transfer-factory
  choiceContext: Record<string, unknown>; // raw choiceContext.choiceContextData from Scan
  inputHoldingCids: string[];     // from your ACS query
  dsoParty: string;               // instrument admin (e.g. DSO on the network)
  receiverPartyId: string;
}) {
  const { isConnected, party } = useAccount();
  const { ledgerApi, isLoading, error } = useLedgerApi();

  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' },
                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: [],
    };

    const result = await ledgerApi({
      requestMethod: 'POST',
      resource: '/v2/commands/submit-and-wait',
      body: JSON.stringify(payload),
    });

    if (result) {
      console.log('TransferInstruction created:', JSON.parse(result.response));
    }
  };

  if (!isConnected) return null;

  return (
    <>
      <button onClick={handleTransfer} disabled={isLoading}>
        {isLoading ? 'Submitting…' : 'Transfer 10 Amulet'}
      </button>
      {error && <p>Error: {error.message}</p>}
    </>
  );
}
⚠️ Warning
Submission path depends on the wallet. The raw 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.
ℹ️ 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.

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:

tsx
import { loop } from '@fivenorth/loop-sdk';

// After connecting via PartyLayer, loop.provider is the same provider instance.
await loop.wallet.transfer(
  receiverPartyId,   // "party::fingerprint"
  '10',              // amount as string
  { instrument_admin: dsoParty, instrument_id: 'Amulet' },
  { message: 'Payment for invoice #42', executionMode: 'wait' },
);

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.

💡 Tip
This helper works only with Loop. For Console / Nightly / Bron / Send, use the Token Standard command flow above; those wallets do not expose a high-level transfer helper. Send fuses the prepare-sign-submit steps into 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:

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

await client.ledgerApi({
  requestMethod: 'POST',
  resource: '/v2/commands/submit-and-wait',
  body: JSON.stringify({
    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: [],
  }),
});

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

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

try {
  const result = await client.ledgerApi({
    requestMethod: 'POST',
    resource: '/v2/commands/submit-and-wait',
    body: JSON.stringify(payload),
  });
} catch (err) {
  if (err instanceof UserRejectedError) {
    // User declined in the wallet — safe to retry
  } else if (err instanceof SessionExpiredError) {
    await client.connect();
  } else if (err instanceof CapabilityNotSupportedError) {
    // Wallet doesn't support ledgerApi (e.g. Cantor8)
  } else {
    // Includes our defensive adapter errors:
    //  "requires a request body", "not valid JSON",
    //  "resolved with an empty response", "unexpected response shape"
  }
}

References

PreviousWallet BalancesNextAdvanced