Arkena Docs

Sign and submit transactions

The prepare → sign → execute loop in code, end to end, with every error case.

This is the procedure version of The signing model. Read that one first if you want the why — this page is the how, with copyable code.

The pattern is the same for every state change: NFT buy/list/transfer, swap intent, LP quote create/cancel. Only the endpoint and the summary shape differ.

  1. Prepare

    Code
    const prep = await fetch("/api/wallet/cc-transfer/prepare", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${jwt}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: party,
        to: recipientParty,
        amount: "1.0",
      }),
    }).then((r) => r.json());
    
    // prep === {
    //   commandId: string,
    //   hash: string,
    //   hashingSchemeVersion: string,
    //   transaction: string,
    //   summary: { type, fields... }
    // }

    The backend builds the Canton transaction, including current contract IDs for any input. It returns the hash to sign, the hashing scheme version, and an opaque transaction blob containing disclosed contracts (the wallet doesn't read these — it just verifies the hash).

  2. Sign

    Code
    const { signature } = await provider.request({
      method: "canton_signTransaction",
      params: {
        hash: prep.hash,
        commandId: prep.commandId,
        hashingSchemeVersion: prep.hashingSchemeVersion,
        summary: prep.summary,
      },
    });

    The wallet renders prep.summary as a card and asks the user. On approve, it signs prep.hash with the user's key and resolves with { signature, signedBy, partyId }. On reject, it throws an Error with code: 4001.

  3. Execute

    Code
    const result = await fetch("/api/wallet/cc-transfer/execute", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${jwt}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        commandId: prep.commandId,
        transaction: prep.transaction,
        signature,
      }),
    }).then((r) => r.json());
    
    // result === { commandId, status: "settled" | "pending" | "failed", txId? }

    Most actions return synchronously with status: "settled". Some (notably swap intents) return status: "pending" and require polling.

  4. Poll for async settlement when needed

    Swap intents settle asynchronously. When result.status === "pending", poll the status endpoint:

    Code
    async function waitForSettlement(intentCid: string): Promise<"settled" | "expired" | "cancelled"> {
      for (let i = 0; i < 60; i++) {
        const status = await fetch(`/api/swap/intent/status?intentCid=${intentCid}`, {
          headers: { Authorization: `Bearer ${jwt}` },
        }).then((r) => r.json());
        if (status.state === "settled") return "settled";
        if (status.state === "expired" || status.state === "cancelled") return status.state;
        await new Promise((r) => setTimeout(r, 1000));
      }
      throw new Error("Settlement polling timed out");
    }

    Or skip polling entirely and listen for the txChanged event — see Handle events.

Errors at each stage

Prepare-time errors are HTTP errors from the backend. Standard HTTP status codes:

  • 400 — bad request (validation, missing fields).
  • 401 — JWT missing or expired. Re-run SIWC.
  • 403 — JWT belongs to a different party than the prepare claims.
  • 409 — conflict (insufficient balance, listing already cancelled).

The body has the canonical error shape from Error codes.

Sign-time errors thrown from the wallet:

  • 4001 — user rejected the prompt. Don't retry automatically.
  • 4100 — wallet locked or unauthorized. Surface a passive prompt.
  • -32003 — the wallet's policy rejected the transaction (typically because the displayed summary doesn't match the hash).

Execute-time errors are HTTP errors, similar shape to prepare:

  • 409 — race condition (someone else took the listing first). Refetch state and offer a retry.
  • 503 — settlement timeout from Canton. Safe to retry the same execute call (it's idempotent on commandId).

Idempotency

Each prepare returns a unique commandId. The execute endpoint dedupes on it — retrying with the same body returns the existing result instead of submitting again. This makes "retry on network error" safe.

It does not make "retry on sign-time error" safe. A new signature needs a new prepare.

What's next