Swapper

Agent wallets: pay a deposit safely

If your agent can send transactions, a payable Swapper order hands it a wallet-ready transfer in deposit.payment. It may pay on its own, with no approval step: once, only while payable is true, and only after Swapper's paymentCheck confirms the transfer is exactly the deposit.

Open the swap app →

The rules

  1. No approval step, real checks. Swapper requires no person's approval: an agent may fund an order on its own. What protects the funds is that the agent pays only what the checks below allow, from a wallet with its own spending policy.
  2. Pay only while payable is true. Never pay from a replayed or recovered create without get_swap_status showing payable: true. Read the status just before paying; if payable is false, payableReason says why: do not pay, and never ask for a second deposit.
  3. Check it matches the order. Use deposit.payment only from the create_swap or status response of this order. payment.to (or payment.address for XMR) must equal deposit.address, and the amount must equal deposit.amount (amountAtomic for USDC).
  4. Check the payout address. destination (the payout address recorded with the order) must be the user's own address, known before the order existed. A genuine order created by someone else passes every other check; only this comparison catches it.
  5. Check chain and token. For USDC: chainId 42161 and token 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 (native USDC on Arbitrum One). Anything else, stop: never pay from Ethereum mainnet or another network, never with USDC.e.
  6. Let Swapper check the transfer. Pass what the wallet will sign (the raw signed transaction, or the transaction JSON {to, value, data, chainId}) to get_swap_status as payment. Send only if paymentCheck.matches is true: chain, token, recipient and amount all equal the deposit.
  7. Recompute, don't trust. Rebuild the transfer calldata from the deposit address and amountAtomic and compare it byte for byte with payment.tx.data before signing.
  8. One exact transfer, well before expiry. Send once, the exact amount, before deposit.expiresAt (payable turns false with less than 10 minutes left). Never round, split, top up or resend.
  9. Gas on top. USDC deposits pay gas in ETH on Arbitrum One. XMR deposits pay the Monero network fee on top of the amount.
  10. Keys stay in the wallet. Never ask for, store or log seed phrases or private keys. Give the agent's wallet a spending policy: per-transaction and daily limits, native USDC on Arbitrum One only. Per-transaction approval by a person is optional.
  11. Then track, don't resend. After sending, call get_swap_status with waitSec. If it still shows waiting_for_funding, detection can take several minutes: check the transaction on the explorer instead of paying again.

When an order is payable

Every create_swap and get_swap_status result (REST: create and status responses) says payable. It is true only while the order was opened by create_swap from a quote (origin: "agent_quote"), the provider's order matched the request, the order waits for funding, no deposit transaction was seen, no reconfirmation is pending and its expiry is known and at least 10 minutes away. Only then is deposit.payment non-null. A list of orders never offers a payment (REST: payable: false, replay_check_status). Otherwise payableReason says why:

payableReasonMeaningWhat to do
status_unavailableThe live order could not be read (for example on a replayed or recovered create).Call get_swap_status again later.
upstream_mismatchThe provider's order did not match the quote-bound request; there is no deposit at all.Fund nothing; contact support.
unverified_originThe order was not opened by create_swap (or REST create) from a quote: origin "other", for example one made by the removed raw POST /orders route or one Swapper has no record of. Its destination was never checked against a quote.Never fund it. Create your own order from a quote.
deposit_seenA deposit transaction was already seen.Never send again; track the order.
not_waitingThe order no longer waits for funding.Track it; there is nothing to pay.
reconfirm_requiredThe provider's expected output differs materially from the quote (by default more than 2% below the quoted estimate, since the provider publishes no minimum; or unknown). Nothing clears it.Do not pay. next.action is requote_and_create_new_order: quote again (requoteOf) and create a new order.
expiry_unknownThe deposit expiry is unknown.Do not send; call get_swap_status.
expiredThe deposit window has closed while the provider still shows the order waiting.Do not send; quote again for a new order. A deposit already sent is a support case.
expiringLess than 10 minutes to the deposit expiry.Do not send; quote again for a new order.
attestation_unverifiedMCP only: Swapper's signature on this order's deposit instructions (deposit.attestation) did not verify against the server's key set, or is missing where the server requires one.Fund nothing; open a support ticket with the orderId.
replay_check_statusA list of orders (list_swaps, find my swaps): it never offers a payment.Call get_swap_status; pay only if it shows payable: true.

What deposit.payment contains

While payable is true, the deposit from create_swap, POST /api/v1/agent/swap/create and status responses carries payment, a wallet-ready transfer of exactly that deposit. Otherwise it is null: then do not pay.

FieldKindValue
kindbothmonero or erc20_transfer: which shape follows.
uri, address, amountmoneromonero:<address>?tx_amount=<amount>, the exact human decimal amount, never rounded.
chainId, token, to, amountAtomicerc20_transfer42161, the native USDC contract, the deposit address and the amount in atomic units (6 decimals).
eip681erc20_transferethereum:<token>@42161/transfer?address=<to>&uint256=<amountAtomic>
txerc20_transfer{ to: <token>, value: "0x0", data, chainId: 42161 }: a complete transaction request, where data = 0xa9059cbb + recipient (lowercase, 32 bytes) + amount (hex, 32 bytes).
gasboth{ symbol, chain, note } from the shared funding guide: ETH on Arbitrum One, or the Monero network fee.
signingbothSwapper never signs or holds keys. Only the sender's wallet signs and sends it: the user's own, or an agent's wallet acting for the user.

Worked example: USDC on Arbitrum One

Deposit of 312.47 USDC (312470000 atomic units) to the example address 0x1111111111111111111111111111111111111111:

{
  "kind": "erc20_transfer",
  "chainId": 42161,
  "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
  "to": "0x1111111111111111111111111111111111111111",
  "amountAtomic": "312470000",
  "eip681": "ethereum:0xaf88d065e77c8cC2239327C5EDb3A432268e5831@42161/transfer?address=0x1111111111111111111111111111111111111111&uint256=312470000",
  "tx": {
    "to": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
    "value": "0x0",
    "data": "0xa9059cbb000000000000000000000000111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000129fe9f0",
    "chainId": 42161
  },
  "gas": {
    "symbol": "ETH",
    "chain": "Arbitrum One",
    "note": "The sending wallet needs a small amount of ETH on Arbitrum One to pay gas for the USDC transfer."
  },
  "signing": "Swapper never signs or holds keys. Only the sender's wallet signs and sends it: the user's own, or an agent's wallet acting for the user."
}
Part of tx.dataValue
Selector transfer(address,uint256)0xa9059cbb
Recipient: deposit address, lowercase, left-padded to 32 bytes0000000000000000000000001111111111111111111111111111111111111111
Amount: amountAtomic in hex, left-padded to 32 bytes00000000000000000000000000000000000000000000000000000000129fe9f0

Autonomous transfer with checks (sketch)

// s: { payable, payableReason, deposit, destination } from get_swap_status for orderId, read
// just before paying (REST: data.payable, data.payableReason, data.deposit, data.destination).
// A create result may be a replay. No person is asked: these checks are the gate.
if (s.payable !== true) throw new Error(`not payable (${s.payableReason}): do not send`);
if (s.destination !== MY_MONERO_ADDRESS) throw new Error("not the user's payout address: do not send");
const d = s.deposit, p = d.payment;
const USDC = "0xaf88d065e77c8cc2239327c5edb3a432268e5831";
const word = (hex) => hex.padStart(64, "0");
const expected = "0xa9059cbb" + word(d.address.slice(2).toLowerCase()) + word(BigInt(d.amountAtomic).toString(16));
const ok = p && p.kind === "erc20_transfer" && p.chainId === 42161
  && p.token.toLowerCase() === USDC && p.tx.to.toLowerCase() === USDC
  && p.to === d.address && p.amountAtomic === d.amountAtomic
  && p.tx.value === "0x0" && p.tx.data === expected
  && Date.parse(d.expiresAt) - Date.now() > 10 * 60_000;
if (!ok) throw new Error("payment does not match the order: do not send");
// The agent's own spending policy (not a person's approval): here, a per-swap cap.
if (BigInt(d.amountAtomic) > MAX_USDC_ATOMIC) throw new Error("over this agent's limit: do not send");

// Swapper checks exactly what will be signed; send once, only if it matches.
const tx = { chainId: 42161, to: p.tx.to, value: p.tx.value, data: p.tx.data };
const r = await mcp.callTool({ name: "get_swap_status", arguments: { orderId, payment: JSON.stringify(tx) } });
if (r.structuredContent?.paymentCheck?.matches !== true) throw new Error("paymentCheck failed: do not send");
await wallet.sendTransaction(tx);

mcp, wallet, MY_MONERO_ADDRESS and MAX_USDC_ATOMIC stand for your MCP client, the agent's wallet tool, the payout address the agent was given and its own limit; Swapper provides none of them. The 10-minute margin mirrors the server's own payable rule. A runnable version (Node 20+, no dependencies; a dry run until you plug in a wallet adapter): autonomous-agent.mjs, with wallet-adapter.example.mjs.

XMR deposits

For XMR to USDC the payment is a Monero URI: monero:<deposit address>?tx_amount=<exact amount>, where tx_amount is the exact amount in XMR as a decimal (the Monero URI scheme defines it in decimal units, not atomic units). The sending wallet adds the network fee on top; the deposit itself must arrive exact, in one transfer.

Building the wallet tool yourself?

What Swapper never does

References

Open the swap app →