Swapper

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.

Open the swap app →

What you need

Missing one? Get USDC on Arbitrum One lists the routes.

In the swap app

  1. 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.
  2. 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).
  3. Set your Monero address. Paste a Monero address you control (95 or 106 characters).
  4. 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.
  5. 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.
  6. 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:

  1. calls get_funding_options and checks you have native USDC and ETH for gas (if you still need to fund, it quotes first so you know how much);
  2. calls get_quote with direction: "u2x" and receiveAmount: "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);
  3. calls create_swap with the quote's next.arguments (its quoteId) without stopping for a yes, and retries with the same arguments (the idempotencyKey defaults to q_<quoteId>, so a retry returns the same order);
  4. funds the order only while payable is true: with a wallet tool it passes the transfer to get_swap_status as payment and signs once, only if paymentCheck.matches and the payout address is yours; without one it shows you the exact deposit;
  5. tracks with get_swap_status (waitSec: 25) until terminal is 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:

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

stateMeaning
waiting_for_fundingOrder open, waiting for the exact deposit before expiresAt. Pay only while payable is true; never send a second deposit.
deposit_detectedDeposit seen on-chain, waiting for confirmations.
swap_processingDeposit confirmed; swap executing.
refundingThe provider is refunding the deposit.
completedTerminal: 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_failureTerminal: 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

ErrorMeaningWhat to do
400 invalid_request, details.reason amount_xor_receiveAmountBoth or neither of amount and receiveAmount were sent.Send exactly one.
400 invalid_request, details.reason too_largereceiveAmount would need a send amount above the accepted maximum.Ask for less, or split it into several swaps.
400 invalid_request, a destination reasonThe 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_requiredCreate 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_reachedThe 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_minimumThe quote is below the provider's minimum swap size (details.minimumSwapUsd).Quote a larger amount.
422 no_routeThe provider has no route for this swap right now.Try another amount, or later (honor Retry-After).
400 invalid_request, details.reason minimum_not_publishedbasis: "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_expiredThe quote's acceptance window (300 s, acceptUntil) passed.Re-quote ({"requoteOf": quoteId}) and create from the new quote.
409 quote_mismatchA field in the create body differs from the quote.Create with just the quoteId.
409 quote_already_usedThis 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_unknownThe 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_mismatchThe provider's order did not match the quote-bound request; no deposit was issued.Fund nothing and do not retry. Contact support.
451 geo_blockedRestricted region (including the US).Stop.
429 rate_limitedToo 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.

Open the swap app →