ORCHESTRATION API

Verify Payloads

What to check in the returned transaction payloads.

POST /quote returns fully-encoded transaction payloads, ready to sign and submit. This page explains what to check in those payloads before signing, so you can confirm the quote is valid and the transaction is safe.

The Deployments pages list every M0 contract and program per chain, with explorer and ABI links. Pin your allowlist to those pages and not to addresses observed in previous API responses.

Quote-level checks

Every Quote echoes your request back. Before looking at individual payloads, confirm:

FieldCheck
routesource and destination match the chain and token address you requested
recipientMatches the recipient you passed, or your sender if you omitted it
amountInMatches the amount you requested, in the token's smallest unit
amountOutWithin your expectations for the route; reject quotes with an implausible rate

Then verify each entry in payloads according to its runtime, discriminated by payload.data.type ("evm" or "svm"). Payloads execute in order, so check all of them before signing the first.

EVM payloads

An EVM payload's data is a complete transaction for your wallet to sign. Its shape is { type: "evm", chain, chainId, to, data, value }. Check:

  • Chain. chain and chainId. Pin that mapping yourself rather than deriving it from the response. The first leg always executes on route.source.chain, and approvals execute on the same chain as the leg they approve. Multi-hop routes include legs on intermediate chains: each leg must execute either on the same chain as the previous leg or on the destination chain decoded from the previous bridge leg's calldata.
  • Target contract (to) is known. For action legs (swap, bridge, order), to must be a contract listed on the Deployments pages (e.g. SwapFacility, a Portal). For approval legs, to is the input token itself, the address from your route.source, which also appears on the Deployments pages and in GET /supported-assets. For third-party legs (wormhole-cctp), verify against that provider's official deployment docs.
  • Amount. Decode the calldata and confirm the amount equals the1 quote's amountIn.
  • Recipient. On the final leg, confirm the decoded recipient equals the quote's recipient.
  • Approvals are exact and correctly scoped. An approval leg is approve(spender, amount) where spender is the contract executing the next payload and amount is exactly amountIn. The API never asks for unlimited approvals; reject any payload that does.
  • Native value. value is "0" for approvals and same-chain swaps. Bridge legs carry the cross-chain delivery fee in value; sanity-check its magnitude against current gas prices.

Example: decoding and checking an approval leg

import { decodeFunctionData, erc20Abi } from "viem";
import type { components } from "./m0-swap"; // From type generation

type Quote = components["schemas"]["Quote"];
type EvmPayload = components["schemas"]["EvmPayload"];

function verifyApprovalLeg(
  quote: Quote,
  payload: EvmPayload,
  nextLegTo: string,
) {
  // Approval legs call approve() on the input token itself
  if (payload.to.toLowerCase() !== quote.route.source.address.toLowerCase()) {
    throw new Error("approval target is not the input token");
  }

  const { functionName, args } = decodeFunctionData({
    abi: erc20Abi,
    data: payload.data as `0x${string}`,
  });
  if (functionName !== "approve") throw new Error("unexpected function");

  const [spender, amount] = args;
  if (spender.toLowerCase() !== nextLegTo.toLowerCase()) {
    throw new Error("spender is not the contract executing the next leg");
  }
  if (amount !== BigInt(quote.amountIn)) {
    throw new Error("approval amount does not equal amountIn");
  }
}

For action legs, decode against the contract's ABI (linked from the Deployments tables) and apply the same amount and recipient checks. As a final safety net, simulate the transaction (eth_call / Viem simulateContract) and inspect the resulting balance changes before broadcasting.

Permits

The opt-in permit flow replaces the on-chain approval with an off-chain EIP-2612 signature, so the verification target shifts: what you sign is the recipe's typedData, and a bad permit is as dangerous as a bad approval. Before signing a recipe from POST /permit/quote, check:

FieldCheck
typedData.message.ownerYour address (the account funding the input tokens)
typedData.message.spenderEquals recipe.spender and is a known contract on Deployments
typedData.message.valueExactly the quote's amountIn, never unlimited
typedData.domain.verifyingContractThe input token address from your route.source
typedData.domain.chainIdThe source chain's id
typedData.message.deadlineNear-future timestamp, not far beyond the quote's expiresAt

Payloads returned by POST /permit/build are the same EvmPayload shape as /quote, so all the EVM checks above apply to them unchanged.

You can also skip the build round-trip entirely: the recipe carries everything needed to encode the final calldata offline, which means you construct, and therefore fully control, the exact transaction you sign. See Self-encoding.

SVM payloads

An SVM payload is { type: "svm", chain, transaction }, where transaction is a base64-encoded Solana VersionedTransaction. Multi-step Solana routes are combined server-side into a single transaction where possible, and the server pre-signs any auxiliary signers it controls. Your wallet adds the fee-payer signature. Deserialize to inspect the transaction. Check:

  • Fee payer is your sender. The first account key (the fee payer and first required signer) must be the sender you passed to /quote.
  • Every invoked program is known. Each instruction's program id must be one you recognize: the M0 programs listed in the Solana section of Deployments, standard Solana programs (System, Compute Budget, SPL Token / Token-2022, Associated Token Account), or the bridge programs of the leg's provider (e.g. Wormhole).
  • Resolve lookup tables first. Versioned transactions may load accounts through address lookup tables, so fetch the tables and audit the resolved key set, not just the static keys.
  • Amount and recipient. The destination token account must be the associated token account of the quote's recipient for the destination mint, and the transferred amount must equal amountIn. The most robust way to check both is simulation: run simulateTransaction and inspect the resulting token balance changes.
  • Keep existing signatures intact. The server may have already signed with auxiliary signers. Add your signature to the transaction as-is; mutating the message invalidates the pre-applied signatures.

Example: inspecting an SVM payload

import { Connection, PublicKey, VersionedTransaction } from "@solana/web3.js";
import type { components } from "./m0-swap"; // From type generation

type SvmPayload = components["schemas"]["SvmPayload"];

// Pin to /resources/addresses. Do not learn addresses from API responses
const KNOWN_PROGRAMS = new Set([
  "mzp1q2j5Hr1QuLC3KFBCAUz5aUckT6qyuZKZ3WJnMmY", // M0 Portal
  "11111111111111111111111111111111", // System
  "ComputeBudget111111111111111111111111111111", // Compute Budget
  "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", // Token-2022
  "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", // SPL Token
  "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", // Associated Token Account
  // ...plus the provider's bridge programs (e.g. Wormhole) for cross-chain legs
]);

async function verifySvmPayload(
  connection: Connection,
  payload: SvmPayload,
  sender: string,
) {
  const tx = VersionedTransaction.deserialize(
    Buffer.from(payload.transaction, "base64"),
  );

  // 1. Fee payer (first required signer) must be your sender
  const feePayer = tx.message.staticAccountKeys[0];
  if (!feePayer.equals(new PublicKey(sender))) {
    throw new Error("unexpected fee payer");
  }

  // 2. Resolve address lookup tables so every account key is visible
  const lookupTables = await Promise.all(
    tx.message.addressTableLookups.map(
      async (lookup) =>
        (await connection.getAddressLookupTable(lookup.accountKey)).value!,
    ),
  );
  const accounts = tx.message.getAccountKeys({
    addressLookupTableAccounts: lookupTables,
  });

  // 3. Every invoked program must be one you recognize
  for (const ix of tx.message.compiledInstructions) {
    const programId = accounts.get(ix.programIdIndex)!.toBase58();
    if (!KNOWN_PROGRAMS.has(programId)) {
      throw new Error(`unknown program: ${programId}`);
    }
  }

  // 4. Simulate and inspect balance changes before signing
  const sim = await connection.simulateTransaction(tx, { sigVerify: false });
  if (sim.value.err) {
    throw new Error(`simulation failed: ${JSON.stringify(sim.value.err)}`);
  }

  return tx; // sign as-is, do not rebuild the message
}

Notes

  • Payloads embed a recent blockhash (SVM) or current fees (bridge value), so they are short-lived. If verification or signing takes long, re-quote rather than patching payloads.
  • The provider and annotation fields describe each leg and tell you which contract family to expect at to (or which programs to expect on Solana), but they are informational, anchor your checks on the decoded transaction contents and the Deployments pages.
Copyright © M0 Foundation 2026