> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rhinestone.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# End-to-end transaction flow

> Prepare, authorize, submit, and track a multichain transaction.

export const VmCodeGroup = ({children, caption, label = "Virtual machine"}) => {
  const [vm, setVm] = useState("evm");
  const [open, setOpen] = useState(false);
  const selectorRef = useRef(null);
  const triggerRef = useRef(null);
  const menuRef = useRef(null);
  const focusIndex = useRef(0);
  const options = [{
    value: "evm",
    title: "EVM",
    icon: "/images/networks/ethereum.svg"
  }, {
    value: "solana",
    title: "Solana",
    icon: "/images/networks/solana.svg"
  }];
  if (typeof caption !== "string" || caption.trim() === "") {
    throw new Error("VmCodeGroup requires a non-empty caption.");
  }
  const selected = options.find(option => option.value === vm);
  const blocks = (Array.isArray(children) ? children : [children]).filter(child => typeof child !== "string" || child.trim() !== "");
  if (blocks.length !== 2 || !blocks.every(block => typeof block?.props?.language === "string" && block.props.numberOfLines !== undefined) || !options.every(option => blocks.filter(block => block.props.filename === option.title).length === 1)) {
    throw new Error("VmCodeGroup requires two fenced code blocks titled EVM and Solana, with no other content.");
  }
  useEffect(() => {
    const path = window.location.pathname;
    const selection = window.__rhinestoneDocsVm;
    if (selection?.path === path) setVm(selection.vm);
    const onChange = event => {
      if (event.detail.path === path) {
        setVm(event.detail.vm);
        setOpen(false);
      }
    };
    window.addEventListener("rhinestone-docs-vm", onChange);
    return () => window.removeEventListener("rhinestone-docs-vm", onChange);
  }, []);
  useEffect(() => {
    if (!open) return;
    menuRef.current?.querySelectorAll("button")[focusIndex.current]?.focus();
    const dismiss = event => {
      if (!selectorRef.current?.contains(event.target)) setOpen(false);
    };
    document.addEventListener("pointerdown", dismiss);
    return () => document.removeEventListener("pointerdown", dismiss);
  }, [open]);
  const selectVm = value => {
    const selection = {
      path: window.location.pathname,
      vm: value
    };
    window.__rhinestoneDocsVm = selection;
    window.dispatchEvent(new CustomEvent("rhinestone-docs-vm", {
      detail: selection
    }));
    triggerRef.current?.focus();
  };
  const openMenu = index => {
    focusIndex.current = index;
    setOpen(true);
    menuRef.current?.querySelectorAll("button")[index]?.focus();
  };
  const handleMenuKey = event => {
    const buttons = Array.from(menuRef.current.querySelectorAll("button"));
    const current = buttons.indexOf(document.activeElement);
    let next;
    if (event.key === "ArrowDown") next = (current + 1) % buttons.length;
    if (event.key === "ArrowUp") next = (current + buttons.length - 1) % buttons.length;
    if (event.key === "Home" || event.key.toLowerCase() === "e") next = 0;
    if (event.key === "End" || event.key.toLowerCase() === "s") next = buttons.length - 1;
    if (next !== undefined) {
      event.preventDefault();
      buttons[next].focus();
    }
    if (event.key === "Tab") setOpen(false);
  };
  return <div className="vm-code-group">
      <div className="vm-code-group-header">
        <span className="vm-code-caption" title={caption}>
          {caption}
        </span>
        <div className="vm-code-selector" ref={selectorRef} onBlur={event => {
    if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
  }} onKeyDown={event => {
    if (event.key === "Escape" && open) {
      event.preventDefault();
      setOpen(false);
      triggerRef.current?.focus();
    }
  }}>
          <button type="button" className="vm-code-trigger" ref={triggerRef} aria-label={`${label}: ${selected.title}`} aria-haspopup="menu" aria-expanded={open} onClick={() => open ? setOpen(false) : openMenu(options.indexOf(selected))} onKeyDown={event => {
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      openMenu(event.key === "ArrowDown" ? 0 : options.length - 1);
    }
  }}>
            <img className="vm-code-icon" src={selected.icon} alt="" width="16" height="16" />
            <span>{selected.title}</span>
            <svg className="vm-code-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path d="m7 10 5 5 5-5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </button>
          {open && <div className="vm-code-menu" role="menu" aria-label={label} ref={menuRef} onKeyDown={handleMenuKey}>
              {options.map(option => <button type="button" role="menuitemradio" aria-checked={vm === option.value} tabIndex={-1} className="vm-code-option" key={option.value} onClick={() => selectVm(option.value)}>
                  <img className="vm-code-icon" src={option.icon} alt="" width="16" height="16" />
                  <span>{option.title}</span>
                  {vm === option.value && <svg className="vm-code-check" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                      <path d="m5 12 4 4L19 6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>}
                </button>)}
            </div>}
        </div>
      </div>
      {options.map(option => {
    const block = blocks.find(block => block.props.filename === option.title);
    const CodeBlock = block.type;
    return <div key={option.value} className="vm-code-group-panel" data-vm={option.value} hidden={option.value !== vm}>
            <CodeBlock {...block.props} filename={undefined} />
          </div>;
  })}
    </div>;
};

Every multichain transaction follows the same lifecycle, but each client exposes different boundaries. Embedded wallets manage preparation, authorization, and submission through `sendIntent()`. The custom signer SDK exposes each step. REST integrations call the endpoints directly.

## Transaction lifecycle

1. Define the destination, delivery, calls, and funding selection for your client.
2. Prepare a route and review its inputs, outputs, fees, and expiry. SDK and direct HTTP integrations also review the plan, requirements, and ordered signing requests.
3. Complete any token requirements for the account type.
4. Authorize the exact prepared route.
5. Submit the signed intent.
6. Track the intent until it completes or fails.

An accepted submission is not an onchain success. Keep the intent ID so you can resume status checks without creating another transaction.

## Follow the flow

Reuse the chains, token addresses, `amount`, and `calls` from [Send a transaction](/transactions/multichain/send-a-transaction).

<Tabs>
  <Tab title="Embedded wallet">
    Use the initialized `wallet` client from [Accounts](/wallets/embedded-wallets/accounts) after the user signs in; sends always use the authenticated account.

    <Steps>
      <Step title="Review, authorize, and submit">
        <VmCodeGroup caption="Submit and retain the outcome" label="Destination VM">
          ```ts EVM theme={null}
          const result = await wallet.sendIntent({
            source: { chain: base.id, token: usdcOnBase },
            destination: {
              chain: arbitrum.id,
              token: usdcOnArbitrum,
              amount,
              calls: [...calls],
            },
            closeOn: "completed",
            waitForHash: true,
          });
          ```

          ```ts Solana theme={null}
          import { solanaDevnet } from "@rhinestone/wallet";
          import { wallet } from "./wallet";

          const current = await wallet.getSession();
          if (!current.success) throw new Error("Sign in first");
          const solana = current.session.accounts.solana;
          if (solana?.status !== "provisioned" || !solana.clusters[solanaDevnet]?.ready) {
            throw new Error("Set up the Devnet wallet before funding it");
          }

          const result = await wallet.sendIntent({
            source: {
              chain: 84532,
              token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
              maxAmount: 2_000_000n,
            },
            destination: {
              chain: solanaDevnet,
              token: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
              amount: 1_000n,
              recipient: solana.address,
            },
            sponsorshipMode: "required",
            closeOn: "completed",
            waitForHash: true,
          });
          ```
        </VmCodeGroup>

        The Solana alternative delivers Base Sepolia USDC to the user's ready Devnet wallet. Use a client configured to expose that managed wallet and follow [setup](/wallets/embedded-wallets/accounts#set-up-a-solana-wallet) first. Delivery itself is EVM-funded and does not need a Solana signature. For Solana-funded sends or instructions, `experimental_solana` and a retained fresh sign-in passkey are required; `SOLANA_SIGN_IN_REQUIRED` means sign in again. See [availability](/wallets/embedded-wallets/networks#availability).

        Embedded wallets prepare the route, handle their smart-account token requirements, request passkey approval, and submit the intent. They do not expose the custom signer SDK's ranked quotes or separate signing and submission methods.

        Sponsorship is required by default. `closeOn: "completed"` waits for completion; `waitForHash: true` separately requests the onchain transaction hash. The user approves the passkey prompt from Rhinestone's [prepared-action review](/wallets/embedded-wallets/clear-signing).

        Persist the whole result before handling errors: `intentId`, `status`, `submitted`, `shouldResubmit`, `targetChain`, `source`, `recipient`, `tokenRequests`, `instructions`, `operationId`, `transactionHash`, and `refund` when present. Do not reduce it to `success` or discard metadata on failure. `transactionHash` is destination-native: a Solana fill uses a base58 signature, not an EVM hash or the origin transaction. Refunds retain their own chain and native identifier. An omitted requested amount means max output, not zero or realized output.

        Save any non-empty `result.intentId` before handling errors. `STATUS_TIMEOUT`, `HASH_TIMEOUT`, or a dialog cancellation with an intent ID can occur after submission. They are not reasons to send a replacement intent. There is no intent history API, so an uncertain submission without an ID cannot be looked up later. Treat `status: "submitted_uncertain"` or `submitted: true` as possibly live, and do not send a replacement automatically.

        For a funded Solana action, wait for delivery to complete before a separate [same-cluster instruction request](/transactions/relaying/relay-a-transaction). Keep both intent IDs: failed execution does not undo completed funding. Sponsorship supplies no instruction assets. Solana instruction sends track until completion, failure, expiry, the `hashTimeoutMs` window (120 seconds by default), or dialog closure; they may take longer to resolve, and tracking never resubmits.

        Logout, wallet switching, or fresh authentication invalidates unsubmitted work. Already dispatched completion may still execute after cancellation. Expired Solana quotes require explicit refresh and another Sign action.
      </Step>

      <Step title="Check or resume execution">
        Use `getIntentStatus()` when you have an intent ID. Check for a lookup error before interpreting the status:

        ```ts {3,8,12,18} theme={null}
        async function waitForWalletCompletion(intentId: string) {
          for (let attempt = 0; attempt < 60; attempt++) {
            const latest = await wallet.getIntentStatus(intentId);

            if (latest.error) {
              console.error(latest.error.code, latest.error.message);
            } else if (
              latest.status === "completed" ||
              latest.status === "failed" ||
              latest.status === "expired"
            ) {
              return latest;
            }

            await new Promise((resolve) => setTimeout(resolve, 2_000));
          }

          return undefined;
        }

        if (!result.intentId) {
          throw new Error(result.error?.message ?? "No intent ID returned");
        }

        const latest = await waitForWalletCompletion(result.intentId);

        if (!latest) {
          console.log("Check this intent again later", result.intentId);
        } else if (latest.status === "completed") {
          console.log(latest.intentId, latest.transactionHash);
        } else {
          console.error("Intent failed", latest.intentId, latest.status);
        }
        ```

        Retain each status response's outcome metadata, including destination-native identifiers and chain-qualified refunds, just as for the send result. The same status helper handles EVM and Solana intents; there is no need for separate VM-specific polling.

        The helper stops after 60 status requests. `undefined` means the outcome is still unknown, not failed; retain the ID for a later check. Stopping your poll does not cancel the intent.

        `getIntentStatus()` returns `success: true` only for `completed`; valid non-terminal statuses return `success: false`. A failed lookup returns `status: "unknown"` with an error such as `STATUS_FAILED` or `NETWORK_ERROR`. An error-free `failed` or `expired` status, by contrast, is an execution outcome.

        Status reads need the owner's signed-in session. To resume after a reload, keep the `intentId` and query it once the same user signs in again. Without a session the lookup returns `APP_ORIGIN_SESSION_REQUIRED`; another user's intent returns `status: "unknown"`, like a missing one. Restoring status does not require `experimental_solana`. A temporary not-found response is not proof that resubmission is safe; `shouldResubmit` is not an instruction to retry automatically.
      </Step>
    </Steps>

    ### Recover from errors

    | Signal | Action |
    | - | - |
    | `STATUS_TIMEOUT` or `HASH_TIMEOUT` | Keep the intent ID and query status. A waiting timeout does not prove execution failure. |
    | `STATUS_FAILED` or `NETWORK_ERROR` during a status lookup | Retry the status read, not the transaction. Check credentials if failures persist. |
    | `USER_CANCELLED` or `USER_REJECTED` | Do not retry automatically. Query any existing intent ID before asking for another approval. |
    | Error-free `failed` or `expired` status | Inspect the failure before asking the user to authorize another intent. |

    Local statuses such as `submitted`, `claimed`, `preconfirmed`, `filled`, and `completed` are not the custom signer SDK's uppercase statuses. Record `error.details?.traceId` when present; do not show raw diagnostics or simulation URLs to users.
  </Tab>

  <Tab title="Custom signer">
    Use the account and backend SDK instance from the [Custom signer quickstart](/wallets/custom-signer/quickstart), plus the constants and calls linked above. The SDK steps below continue one EVM flow. For supported Solana transactions, replace only preparation with a [delivery request](/transactions/multichain/send-a-transaction#deliver-from-solana-to-evm) or [same-chain instruction batch](/transactions/relaying/relay-a-transaction#run-a-solana-instruction-batch); quote selection, signing, submission, and status handling remain shared. Solana spending requires an existing Swig authorized for the configured owner, not automatic EVM deployment. Check [readiness](/transactions/relaying/account-deployment#create-a-solana-swig-before-spending) first.

    <Steps>
      <Step title="Prepare and inspect">
        ```ts theme={null}
        const prepared = await account.prepareTransaction({
          source: { chain: base, token: usdcOnBase },
          destination: { chain: arbitrum, token: usdcOnArbitrum, amount, calls: [...calls] },
        });

        const quote = prepared.quotes.best;
        console.log(quote.cost.input, quote.cost.output, quote.cost.fees);
        console.log(quote.expiresAt, prepared.quotes.traceId);
        console.log(quote.purpose, quote.plan, quote.requirements, quote.signingRequests);
        ```

        `prepared.quotes.best` is the recommended route. To choose another, select it from `prepared.quotes.all`. Show the user what the selected route spends, delivers, and charges before signing. `expiresAt` is a Unix timestamp in seconds.

        The source is Base USDC, not an instruction to search the portfolio. Review `plan.source`, `plan.destination`, and `plan.deployments`, including who executes the calls. The plan describes intended execution, not receipts.

        EVM smart accounts handle approvals and wrapping in the route. A plain EOA must first [complete the selected quote's requirements](#complete-token-requirements). `quote.requirements` is an array with `kind`, CAIP-2 `chainId`, account, `tokenAddress`, bigint `amount`, and an approval `spender` where applicable. Preparation sends no approval or wrap transactions; wait for their receipts before signing.
      </Step>

      <Step title="Sign the selected route">
        ```ts {5} theme={null}
        if (Date.now() >= quote.expiresAt * 1_000) {
          throw new Error("Quote expired; prepare and review a new route");
        }

        const signed = await account.signTransaction(prepared, {
          intentId: quote.intentId,
        });
        ```

        `account.getTransactionMessages(prepared, { intentId: quote.intentId })` returns ordered signing requests. Review each request's account, authority, scope, and validity. Normal EVM `signTransaction` uses configured owners or a session and collects EIP-7702 delegation proofs when needed. Managed Solana signing uses its one configured viem secp256k1 or WebAuthn owner, not an Ed25519 signer. Let the SDK answer the returned Solana signing payloads; do not replace them with a locally reconstructed transaction. `signed.proofs[i]` answers the selected quote's request at index `i`; never sort, deduplicate, or drop slots, even when payloads repeat.

        Never combine proofs from different quotes or feed reconstructed REST sign data into this SDK flow.
      </Step>

      <Step title="Submit and wait">
        ```ts theme={null}
        import { isExecutionError } from "@rhinestone/sdk/errors";

        if (Date.now() >= quote.expiresAt * 1_000) {
          throw new Error("Quote expired; prepare and sign a new route");
        }

        const submitted = await account.submitTransaction(signed);
        console.log(submitted.id, submitted.traceId);

        try {
          const status = await account.waitForExecution(submitted);
          console.log(status.status, status.operations);
        } catch (error) {
          if (error instanceof Error && isExecutionError(error)) {
            console.error(error.message, error.context, submitted.traceId);
          }
          throw error;
        }
        ```

        `submitTransaction` returns a handle after acceptance. Save its `id` and `traceId`. `waitForExecution` polls until completion and throws when execution fails; it has no application deadline. To resume a status check, call `rhinestone.getIntentStatus(submitted.id)` with the SDK instance from the quickstart.
      </Step>
    </Steps>

    ### Interpret status and recover

    | Status | Meaning |
    | - | - |
    | `PENDING` | At least one operation is still processing. Status queries can return this; `waitForExecution` keeps polling. |
    | `COMPLETED` | The intent completed successfully. Inspect recorded operation items for execution evidence. |
    | `FAILED` | Execution failed. Inspect failed onchain items' `failureReason`, EXECUTION results, and error context. |

    Operations are grouped as `{ chainId, items }`; a chain can have several items. Onchain `CLAIM`, `FILL`, and `BRIDGE_FILL` items carry optional native `transaction` references. Offchain `EXECUTION` items carry `result`, not a transaction hash. To resume and inspect recorded references:

    ```ts theme={null}
    const latest = await rhinestone.getIntentStatus(submitted.id);

    for (const group of latest.operations) {
      for (const item of group.items) {
        if (item.type === "EXECUTION") {
          console.log(group.chainId, item.status, item.result.outcome);
          continue;
        }
        if (item.status === "FAILED") console.error(item.failureReason);
        const transaction = item.transaction;
        if (!transaction) continue;
        switch (transaction.vm) {
          case "evm":
          case "stellar":
            console.log(transaction.chainId, transaction.txHash);
            break;
          case "svm":
            console.log(transaction.chainId, transaction.signature);
            break;
          case "tvm":
            console.log(transaction.chainId, transaction.txId);
            break;
          case "unknown":
            console.log(transaction.chainId, transaction.id);
            break;
        }
      }
    }
    ```

    A transaction reference is not proof of finality. `accounts` is optional recorded evidence; do not fabricate missing account addresses. Refund entries contain `transaction`; an empty array means none observed, not that a later refund is impossible. `rhinestone.getIntentStatus(submitted.id, { full: true })` requests additional recorded details; absent fields mean unavailable evidence.

    Re-quote and collect fresh signatures after quote expiry. For `REVERTED`, fix the target calls or stale swap assumptions before requesting another signature. For `BRIDGE_REFUNDED`, inspect the error context for known refunds; a refund does not turn the intent into a success.

    If submission or polling returns an uncertain network result, query the existing intent before creating another. Keep the quote or submission `traceId` for support.

    ### Persist prepared transactions

    Store the entire prepared artifact, including its opaque versioned `request`, canonical `intentInput`, and quotes, with a serialization library that round-trips `bigint` values. Bare `JSON.stringify(prepared)` fails on `bigint` values; converting every `bigint` to a string without restoring its type is not a valid round-trip. Preserve the selected quote ID and any submission handle separately, and protect persisted data from tampering.

    Do not reconstruct the request from displayed costs or convert old artifacts. `InvalidPreparedTransactionError` rejects incompatible prepared data. First reconcile any possibly live intent; only then deliberately prepare, review, and sign a fresh transaction. Restoring an artifact does not extend quote expiry.
  </Tab>

  <Tab title="REST API">
    This direct HTTP example uses `2026-09.caucasus`, not the SDK shapes in the other tabs. It delivers 100 USDC from Base to the same EOA on Arbitrum, without destination calls. Selecting Base USDC means existing Arbitrum USDC does not reduce the delivery.

    Keep the API key on your backend. `walletClientFor(base.id)` supplies the connected EOA's viem signer. It must support the returned EIP-712 payloads, including aggregate data without a domain chain ID.

    <Steps>
      <Step title="Request and select a route">
        ```ts {12,19-30} theme={null}
        const baseUrl = "https://v1.orchestrator.rhinestone.dev";
        const signer = walletClientFor(base.id);
        const [eoaAddress] = await signer.getAddresses();
        if (!eoaAddress) throw new Error("Connect an EOA first");

        const apiKey = process.env.RHINESTONE_API_KEY;
        if (!apiKey) throw new Error("RHINESTONE_API_KEY is required");

        const headers = {
          "Content-Type": "application/json",
          "x-api-key": apiKey,
          "x-api-version": "2026-09.caucasus",
        };

        const quoteResponse = await fetch(`${baseUrl}/quotes`, {
          method: "POST",
          headers,
          body: JSON.stringify({
            account: { evm: { type: "eoa", address: eoaAddress } },
            source: {
              vm: "evm",
              chainId: `eip155:${base.id}`,
              token: usdcOnBase,
            },
            destination: {
              vm: "evm",
              chainId: `eip155:${arbitrum.id}`,
              token: usdcOnArbitrum,
              amount: amount.toString(),
            },
          }),
        });

        if (!quoteResponse.ok) throw new Error(await quoteResponse.text());
        const quote = await quoteResponse.json();
        if (quote.status !== "quoted") throw new Error("Unsupported quote outcome");
        const route = quote.routes[0];
        if (!route) throw new Error("No route available");
        ```

        Routes are server-ranked; `routes[0]` is recommended. Review `purpose`, `cost`, `expiresAt`, settlement layer, `plan.source`, `plan.destination`, and `plan.deployments`. Execution disclosures identify whether the account or solver runs calls. The plan is intended behavior, not completed execution or every internal call. Same-chain routes use `SAME_CHAIN`, including smart accounts.

        Review `requirements` and `signingRequests` before approval. `status: "quoted"` does not mean requirements are fulfilled. See [quote plans and requirements](/api-reference/api-versioning#read-the-quote-plan-and-requirements). Delivery requires a selected source; source-free requests are limited to no-delivery execution with effective sponsorship covering applicable orchestrator costs, not arbitrary caller-authored native call value. See [capability boundaries](/api-reference/api-versioning#respect-capability-boundaries) for existing-Swig requirements and other VM limits.
      </Step>

      <Step title="Complete token requirements" id="complete-token-requirements">
        For HTTP, inspect the always-present `route.requirements` array. An empty array means no preflight operation. Use each entry's explicit account, chain, token, and amount:

        * For `erc20Approval`, call `approve(requirement.spender, requirement.amount)` on the specified ERC-20 token.
        * For `wrapNative`, deposit at least `requirement.amount` into the specified wrapped-native token contract.
        * Wait for each transaction receipt before continuing. Re-quote if the route expires during preflight.

        The custom signer SDK also exposes `prepared.quotes.best.requirements` as an array, using bigint amounts rather than HTTP decimal strings. It does not execute EOA requirements for you.

        For a raw HTTP smart account, use `account.evm.type: "erc7579"` and required `account.evm.initData.setupOps` for an undeployed account. Approvals and wrapping run as preclaim operations instead of EOA requirements. Embedded wallets use the smart-account path through their SDK.
      </Step>

      <Step title="Sign the selected route">
        Answer every `signingRequests[i]` with `proofs[i]`, in returned order. Never sort, deduplicate, or omit repeated payloads. This example rejects anything outside the configured EOA's secp256k1 EIP-712 path:

        ```ts theme={null}
        const proofs = [];

        for (const request of route.signingRequests) {
          const { payload, authority } = request;
          if (
            request.account.vm !== "evm" ||
            request.account.address.toLowerCase() !== eoaAddress.toLowerCase() ||
            authority.kind !== "secp256k1" ||
            authority.address.toLowerCase() !== eoaAddress.toLowerCase() ||
            payload.kind !== "eip712" ||
            payload.signatureFormat !== "secp256k1"
          ) {
            throw new Error("Unsupported signing request or authority");
          }

          if (Date.now() >= route.expiresAt * 1_000) {
            throw new Error("Quote expired; request and review a new route");
          }
          for (const constraint of request.validity) {
            if (constraint.kind !== "timestamp") {
              throw new Error("Unsupported validity constraint");
            }
            if (Date.now() >= constraint.expiresAt * 1_000) {
              throw new Error("Signing request expired; re-quote");
            }
          }

          proofs.push({
            kind: "eip712",
            signature: await signer.signTypedData({
              ...payload.typedData,
              account: eoaAddress,
            }),
          });
        }
        ```

        Review the request's `chainIds` and scope before signing. Aggregate data can omit `typedData.domain.chainId`; do not invent one or rewrite the supplied typed data.

        These proofs authorize only this route. Raw smart-account requests need their account-format signature encoding. For other payload kinds and validity constraints, implement [signing dispatch](/api-reference/api-versioning#sign-requests-and-submit-ordered-proofs) or use the SDK.
      </Step>

      <Step title="Submit the selected route">
        ```ts theme={null}
        if (Date.now() >= route.expiresAt * 1_000) {
          throw new Error("Quote expired; request and sign a new route");
        }

        const submitResponse = await fetch(`${baseUrl}/intents`, {
          method: "POST",
          headers,
          body: JSON.stringify({
            intentId: route.intentId,
            proofs,
          }),
        });

        if (!submitResponse.ok) throw new Error(await submitResponse.text());
        const result = await submitResponse.json();
        ```

        The `{ intentId }` response acknowledges submission, not completion. Keep `route.intentId` even if the response is lost; query it before creating a replacement. An expired or missing stored quote requires re-quoting and fresh proofs, not replaying stale proofs or converting Blanc signatures. See [ordered proof submission](/api-reference/api-versioning#sign-requests-and-submit-ordered-proofs).
      </Step>

      <Step title="Track execution">
        ```ts theme={null}
        async function pollIntent(intentId: string) {
          for (let attempt = 0; attempt < 60; attempt++) {
            const response = await fetch(`${baseUrl}/intents/${intentId}`, { headers });
            if (!response.ok) throw new Error(await response.text());

            const latest = await response.json();
            if (latest.status === "COMPLETED" || latest.status === "FAILED") {
              return latest;
            }

            await new Promise((resolve) => setTimeout(resolve, 2_000));
          }

          return undefined;
        }

        const latest = await pollIntent(result.intentId);
        console.log(result.intentId, latest);
        ```

        `PENDING` is non-terminal. An exhausted poll or failed read leaves the outcome unknown; retain the string intent ID and query again later. On `FAILED`, inspect `operations` for `chainId`, the failed operation, and `failureReason`.

        Operations carry native references in `items[].transaction`; account summaries are optional `accounts[]`. Refunds also carry `{ transaction }`. Extract native identifiers without assuming every VM uses `txHash`:

        ```ts theme={null}
        const transactions = latest?.operations.flatMap((operation) =>
          operation.items.flatMap((item) => item.transaction ? [item.transaction] : []),
        ) ?? [];

        for (const transaction of transactions) {
          let id;
          switch (transaction.vm) {
            case "evm":
            case "stellar": id = transaction.txHash; break;
            case "svm": id = transaction.signature; break;
            case "tvm": id = transaction.txId; break;
            case "unknown": id = transaction.id; break;
            default: continue;
          }
          console.log(transaction.vm, transaction.chainId, id);
        }
        ```

        A transaction reference is not proof of finality. `refunds: []` means no refund has been observed, not that a failed intent cannot later be refunded. Historical `vm: "unknown"` references can retain numeric chains; do not invent an EVM chain reference.

        ### Read full details

        Fetch `GET /intents/{intentId}?full=true` with the same headers for extended details. The ID stays at top-level `intentId`. Details include an optional structured recipient `{ vm, chainId, address }`, source/destination legs with `chainId` and `transaction`, intended execution in `details.executions.source[]` and `.destination`, optional deployments, and a hex nonce. Missing historical accounts, authorities, executions, or deployments mean unavailable data—not that nothing happened. See [intent read migration](/api-reference/api-versioning#update-intent-reads).
      </Step>
    </Steps>

    ### List intent history

    `GET /intents` returns the authenticated API client's history across accounts, newest first—not just the EOA above or the logged-in user's wallet. Keep this client-wide list backend-only and enforce user/account ownership before exposing records to an application user.

    The response is `{ data, pagination }`. Pass `pagination.nextCursor` unchanged; it is not an intent ID. This example reads the first two pages when available:

    ```ts theme={null}
    async function readIntentPage(cursor?: string) {
      const url = new URL(`${baseUrl}/intents`);
      url.searchParams.set("limit", "20");
      if (cursor !== undefined) url.searchParams.set("cursor", cursor);
      const response = await fetch(url, { headers });
      if (!response.ok) throw new Error(await response.text());
      return response.json();
    }

    const page = await readIntentPage();
    for (const item of page.data) {
      console.log(
        item.intentId,
        item.accounts,
        item.sourceChainIds,
        item.destinationChainId,
        item.value?.side,
        item.value?.chainId,
        item.value?.tokenAddress,
        item.value?.amount,
      );
    }
    const nextPage = page.pagination.nextCursor
      ? await readIntentPage(page.pagination.nextCursor)
      : undefined;
    ```

    `accounts`, `destinationChainId`, and `value` can be absent. `value.side` distinguishes planned output from retained input, not realized delivery or a balance change. Deployment-only and historical multi-token records can omit a truthful single value. See [intent list migration](/api-reference/api-versioning#update-intent-lists).
  </Tab>
</Tabs>

<CardGroup cols={2}>
  <Card title="Send a transaction" icon="send" href="/transactions/multichain/send-a-transaction">
    Define delivery, calls, and funding selection.
  </Card>

  <Card title="Unified balance" icon="wallet" href="/transactions/multichain/unified-balance">
    Read spendable balances across supported chains.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.