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.
The rules
- 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.
- 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.
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- Gas on top. USDC deposits pay gas in ETH on Arbitrum One. XMR deposits pay the Monero network fee on top of the amount.
- 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.
- 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:
| payableReason | Meaning | What to do |
|---|---|---|
status_unavailable | The live order could not be read (for example on a replayed or recovered create). | Call get_swap_status again later. |
upstream_mismatch | The provider's order did not match the quote-bound request; there is no deposit at all. | Fund nothing; contact support. |
unverified_origin | The 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_seen | A deposit transaction was already seen. | Never send again; track the order. |
not_waiting | The order no longer waits for funding. | Track it; there is nothing to pay. |
reconfirm_required | The 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_unknown | The deposit expiry is unknown. | Do not send; call get_swap_status. |
expired | The 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. |
expiring | Less than 10 minutes to the deposit expiry. | Do not send; quote again for a new order. |
attestation_unverified | MCP 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_status | A 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.
| Field | Kind | Value |
|---|---|---|
kind | both | monero or erc20_transfer: which shape follows. |
uri, address, amount | monero | monero:<address>?tx_amount=<amount>, the exact human decimal amount, never rounded. |
chainId, token, to, amountAtomic | erc20_transfer | 42161, the native USDC contract, the deposit address and the amount in atomic units (6 decimals). |
eip681 | erc20_transfer | ethereum:<token>@42161/transfer?address=<to>&uint256=<amountAtomic> |
tx | erc20_transfer | { to: <token>, value: "0x0", data, chainId: 42161 }: a complete transaction request, where data = 0xa9059cbb + recipient (lowercase, 32 bytes) + amount (hex, 32 bytes). |
gas | both | { symbol, chain, note } from the shared funding guide: ETH on Arbitrum One, or the Monero network fee. |
signing | both | 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. |
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.data | Value |
|---|---|
Selector transfer(address,uint256) | 0xa9059cbb |
| Recipient: deposit address, lowercase, left-padded to 32 bytes | 0000000000000000000000001111111111111111111111111111111111111111 |
| Amount: amountAtomic in hex, left-padded to 32 bytes | 00000000000000000000000000000000000000000000000000000000129fe9f0 |
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?
- Leave
readOnlyHintunset (orfalse) on the tool that sends funds. OpenAI's docs say ChatGPT treats tools withoutreadOnlyHintas write actions and asks for confirmation by default; the user can let it remember the choice for the rest of a conversation. - Other clients may ignore annotations and other hints. Put your own policy check (spending limit, token and chain, the payout address,
paymentCheck) in front of every transfer; a person's confirmation is optional. - Log the decoded transfer (token, network, recipient, amount) for the person the agent acts for, not just the calldata.
What Swapper never does
- Sign, send or hold funds, or ask for wallet access.
- Ask for seed phrases, private keys or API keys.
- Send payment requests outside an order: deposit data only comes from the create and status responses.
References
- EIP-681: URL format for transaction requests (Final; the
uint256amount is in atomic units). Checked 2026-10-07. - Monero URI scheme (
tx_amountin decimal units). Checked 2026-10-07. - Circle: USDC contract addresses. Checked 2026-10-07.
- OpenAI: Add custom MCP server (write-action confirmations, remembered choices). Checked 2026-10-08.