Buy XMR with USDC
Swap native USDC on Arbitrum One for Monero sent to your own address. Say how much USDC to spend, or ask for the XMR you want.
What you need
- Native USDC on Arbitrum One (contract
0xaf88d065e77c8cC2239327C5EDb3A432268e5831) in a wallet you control. Not USDC.e, not another network. - A little ETH on Arbitrum One to pay gas for the transfer.
- A Monero address you control (95 or 106 characters).
Missing one? Get USDC on Arbitrum One lists the routes.
In the swap app
- Get native USDC and gas. Hold native USDC on Arbitrum One (contract 0xaf88d065e77c8cC2239327C5EDb3A432268e5831) and a little ETH there for gas, in a wallet you control.
- Quote. In the app, choose USDC → XMR and enter the USDC amount. Check the estimated XMR: it is an estimate, not a guarantee (the provider publishes no minimum receive amount; the final amount is set when the swap runs).
- Set your Monero address. Paste a Monero address you control (95 or 106 characters).
- Create the order. Press Get deposit address. Once Swapper has checked the provider's order against your quote, you see a one-time deposit address on Arbitrum One, the exact USDC amount and an expiry time, shown only while at least 10 minutes remain. Nothing has moved yet.
- Send the exact deposit. Send exactly that amount of native USDC in one transfer before the expiry, from your own wallet. Gas is paid in ETH on top.
- Track it. This route usually completes in about 5 minutes once funded. The order shows completed when the provider reports the XMR sent: check your Monero wallet.
The app quotes by the USDC you send. To ask for an exact XMR amount ("I want 1 XMR"), use an agent or the API below.
With an AI agent
Connect your client in one line (MCP setup), then ask: "Get me 1 XMR at YOUR_MONERO_ADDRESS, paying with USDC on Arbitrum One." Or start the prompt swap_usdc_to_xmr with xmrAmount. The agent can run the whole swap on its own:
- calls
get_funding_optionsand checks you have native USDC and ETH for gas (if you still need to fund, it quotes first so you know how much); - calls
get_quotewithdirection: "u2x"andreceiveAmount: "1", then shows the USDC to send, the XMR estimate (the provider publishes no minimum, so you may receive less) and the expiry (quotes are valid for 300 seconds); - calls
create_swapwith the quote'snext.arguments(itsquoteId) without stopping for a yes, and retries with the same arguments (theidempotencyKeydefaults toq_<quoteId>, so a retry returns the same order); - funds the order only while
payableis true: with a wallet tool it passes the transfer toget_swap_statusaspaymentand signs once, only ifpaymentCheck.matchesand the payout address is yours; without one it shows you the exact deposit; - tracks with
get_swap_status(waitSec: 25) untilterminalis true.
See the full example conversation.
By API: ask for the XMR you want
curl -sS https://xmrtousdc.xyz/api/v1/agent/swap/quote \
-H "content-type: application/json" \
-d '{"direction":"u2x","receiveAmount":"1","to":"YOUR_MONERO_ADDRESS"}'
To quote by the XMR you want to receive (an estimate: the provider publishes no minimum, so basis: "minimum" is refused with minimum_not_published), send receiveAmount instead of amount (USDC you send): exactly one of the two, with direction: "u2x". Swapper searches a send amount whose estimated output meets the target: it rounds up to 0.01 USDC and uses at most 5 provider quotes. The response is a normal quote for that amount (data.amount, bound to data.quoteId) plus:
data.target:basis,receiveAmount,estimateMeetsTarget: true,minimumMeetsTarget: null(there is no published minimum to compare),upstreamQuotesand a note. Nothing is guaranteed: the final amount can fall short of the target.data.prerequisites(MCP:prerequisitesat the top level): native USDC contract and the ETH gas note, before any order exists.data.terms: the estimate (receive.minimumis null andminimumPublishedfalse),guaranteed: false, the fees as the provider reports them,minimumSwapUsdandbelowProviderMinimum(create refuses a quote below the provider's minimum swap value).data.expiresAt,data.expiresInSecanddata.acceptWindowSec(300): how long the quote can be accepted (create the order before then).
Then create the order from the quote alone (no confirmation step is required), with a new random idempotency key. Add to only if the quote had none; any other field you send must match the quote. Each quote opens at most one order.
KEY=$(uuidgen) # a new random key per order; reuse it only to retry this create
curl -sS https://xmrtousdc.xyz/api/v1/agent/swap/create \
-H "content-type: application/json" \
-H "idempotency-key: $KEY" \
-d '{"quoteId":"QUOTE_ID"}'
Retry a failed or timed-out create with the same key: it returns the same order, never a second one. Quote expired, price moved, or the user funded their wallet first? Re-quote the same inputs; data.delta compares the new estimate (and send amount) with the old ones:
curl -sS https://xmrtousdc.xyz/api/v1/agent/swap/quote \
-H "content-type: application/json" \
-d '{"requoteOf":"QUOTE_ID"}'
Paying the deposit
The create response has data.payable, data.deposit (address, exact amount, expiry) and data.deposit.payment, a wallet-ready transfer of exactly that deposit: an EIP-681 link plus the unsigned ERC-20 call. Wallets that understand EIP-681 links can open payment.eip681; otherwise copy the address and the exact amount. payment is non-null only while payable is true: the order was created from the quote (data.origin: "agent_quote"), the provider's order matched the request, it waits for funding, no deposit was seen, no reconfirmation is pending and at least 10 minutes remain before expiry. Otherwise data.payableReason says why: do not pay. Show data.destination (the payout address recorded with the order) either way. Before showing a deposit again (after a retry, a replay or in a new conversation), read the status; if payable is false, never ask for another deposit. Example (illustrative deposit address and amount):
{
"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."
}
Paying from an agent's wallet, with no one in the loop? Follow Agent wallets: pay a deposit safely.
Waiting for the XMR
| state | Meaning |
|---|---|
waiting_for_funding | Order open, waiting for the exact deposit before expiresAt. Pay only while payable is true; never send a second deposit. |
deposit_detected | Deposit seen on-chain, waiting for confirmations. |
swap_processing | Deposit confirmed; swap executing. |
refunding | The provider is refunding the deposit. |
completed | Terminal: the provider reports the payout sent to the destination. evidence.payout is the payout transaction it reports; when that is null, check the wallet before treating the funds as received. |
terminal_failure | Terminal: outcome failed, expired or refunded. failed/expired can still change (mayChange) if funds arrived late. Open a support ticket if funds were sent. |
Long-poll instead of polling fast. The call returns as soon as the state differs from sinceState, or after waitSec (max 25) with data.waited.changed: false; repeat with the new state until terminal is true. If you already sent the deposit, do not send again: detection can take several minutes. data.trackUrl opens the order in the swap app.
curl -sS "https://xmrtousdc.xyz/api/v1/agent/swap/status/ORDER_ID?waitSec=25&sinceState=waiting_for_funding"
Lost the order ID? Look it up by the idempotency key you used for create (it finds orders created over REST or MCP; addresses are redacted). Over MCP, list_swaps takes the same idempotencyKey.
curl -sS "https://xmrtousdc.xyz/api/v1/agent/swap/orders?idempotencyKey=$KEY"
If something goes wrong
| Error | Meaning | What to do |
|---|---|---|
400 invalid_request, details.reason amount_xor_receiveAmount | Both or neither of amount and receiveAmount were sent. | Send exactly one. |
400 invalid_request, details.reason too_large | receiveAmount would need a send amount above the accepted maximum. | Ask for less, or split it into several swaps. |
400 invalid_request, a destination reason | The Monero address failed a check: invalid_monero_address, bad_monero_checksum (a typo), wrong_network (not mainnet) or wrong_direction (an 0x address). | Copy the address again from the Monero wallet; never edit it by hand. |
400 idempotency_key_required | Create was sent without an idempotency-key header. | Send a new random UUID per order and reuse it only for retries of that create. |
422 target_not_reached | The search budget (5 provider quotes) ran out before an estimate met receiveAmount. | error.details.bestSendAmount and bestEstimate show the closest result. Quote again with amount, or another target. |
422 below_provider_minimum | The quote is below the provider's minimum swap size (details.minimumSwapUsd). | Quote a larger amount. |
422 no_route | The provider has no route for this swap right now. | Try another amount, or later (honor Retry-After). |
400 invalid_request, details.reason minimum_not_published | basis: "minimum" was sent, but the provider publishes no minimum receive amount. | Quote without basis: the estimate is the only target, and nothing is guaranteed. |
410 quote_expired | The quote's acceptance window (300 s, acceptUntil) passed. | Re-quote ({"requoteOf": quoteId}) and create from the new quote. |
409 quote_mismatch | A field in the create body differs from the quote. | Create with just the quoteId. |
409 quote_already_used | This quote already opened an order under another key (details.orderId; null while that create's outcome is unknown). | Check that order's status, or retry the first create with its own key; a new order needs a new quote and a new key. |
503 order_outcome_unknown | The order may exist, but the response was lost. | Retry create with the same idempotency-key until it resolves (details.resolvesAt, up to 10 minutes). Never fund an address you did not receive. |
502 upstream_mismatch | The provider's order did not match the quote-bound request; no deposit was issued. | Fund nothing and do not retry. Contact support. |
451 geo_blocked | Restricted region (including the US). | Stop. |
429 rate_limited | Too many requests from this IP. | Wait for Retry-After, then retry the same call. |
Funds sent and the order failed or looks stuck? Email support@xmrtousdc.xyz with the ord_... ID, or open a ticket with create_support_ticket.