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 / off-ledger API. For Amulet on mainnet/devnet, that's the /registry/transfer-instruction/v1/transfer-factory endpoint of the DSO's Scan (it returns both the factoryId and a ChoiceContext 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 { useSession, 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;             // from Scan /transfer-factory
  choiceContext: Record<string, unknown>; // from Scan
  inputHoldingCids: string[];     // from your ACS query
  dsoParty: string;               // instrument admin (e.g. DSO on the network)
  receiverPartyId: string;
}) {
  const session = useSession();
  const { ledgerApi, isLoading, error } = useLedgerApi();

  const handleTransfer = async () => {
    if (!session) 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: session.partyId,
                receiver: receiverPartyId,
                amount: '10.0',
                instrumentId: { admin: dsoParty, id: 'Amulet' },
                requestedAt: nowIso,
                executeBefore: expiresIso,
                inputHoldingCids,
                meta: { values: {} },
              },
              // ChoiceContext.values is a TextMap AnyValue. Each entry must
              // use the tagged-union form, e.g.
              //   { tag: 'AV_ContractId', value: '<cid>' }
              //   { tag: 'AV_Text',       value: '<string>' }
              //   { tag: 'AV_Party',      value: '<party>' }
              // The Scan /transfer-factory response already ships these values
              // in the correct shape — pass them through verbatim.
              extraArgs: {
                context: { values: choiceContext },
                meta: { values: {} },
              },
            },
          },
        },
      ],
      commandId: crypto.randomUUID(),
      applicationId: 'my-app',
      actAs: [session.partyId],
      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 (!session) return null;

  return (
    <>
      <button onClick={handleTransfer} disabled={isLoading}>
        {isLoading ? 'Submitting…' : 'Transfer 10 Amulet'}
      </button>
      {error && <p>Error: {error.message}</p>}
    </>
  );
}
ℹ️ Note
Where do factoryCid and choiceContext come from? From the Splice Scan HTTP API that the registry issuer runs. For the Amulet DSO on devnet/mainnet, you call GET /api/scan/v0/registry/transfer-instruction/v1/transfer-factory (body includes the sender and receiver) and it returns { factoryId, choiceContext, disclosedContracts }. This is dApp-side off-ledger coordination — PartyLayer does not abstract it because the endpoint varies by app provider.

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' },
);
💡 Tip
This path works only with Loop. For Console / Nightly / Bron, use the Token Standard command flow above — those wallets do not expose a high-level transfer helper.

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: { values: acceptChoiceContext }, // tagged TextMap AnyValue from Scan
            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 session.partyId.

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