Veil OTC Swap — Client API
Swap USDC (Arbitrum) → XMR (Monero) by submitting a signed order over Signal, then sending the USDC. You don't specify an amount — the order authorizes a destination, and the desk swaps whatever USDC you send and delivers the XMR to the Monero address you named.
You stay in control of where funds go: every order is signed by the wallet you send from. Even if your Signal messages were intercepted, nobody can redirect your XMR — the destination is part of what you cryptographically sign, and only your private key can produce that signature.
1. How it works (the flow)
1. You build an order: from-address, destination-XMR-address (NO amount)
2. You SIGN it with the private key of the from-address
3. You send the signed order to the desk over Signal
→ desk replies "ACCEPTED" (or an error)
4. You send USDC on Arbitrum — ANY amount — from that exact from-address, to the desk address
5. Desk detects the deposit, matches it to your signed order, buys XMR with whatever arrived,
and sends it to your destination address
→ desk replies with a receipt (Monero txid)
Order of steps 2–4 matters: submit the signed order first, then send the USDC. A deposit is matched to your order by its from-address. The order is a standing authorization: once accepted, every deposit you send from that address (until the order expires) is swapped to the destination you signed. If you send funds with no order on file, the desk holds them for manual handling — it will not auto-guess a destination.
To change your destination, just sign and submit a new order from the same address — it supersedes the previous one (your latest signed destination always wins).
2. The order
An order is a small JSON object with five fields:
{
"from": "0xYourArbitrumAddress",
"to_xmr": "8YourMoneroDestinationAddress",
"nonce": "1718600000-abc",
"expiry": 1718603600,
"signature": "0x….(130 hex chars)"
}
| field | meaning |
|---|---|
from | The Arbitrum address you will send the USDC from. Must be one of your registered wallet's addresses. |
to_xmr | Monero address to receive the XMR. Standard (4…) or sub-address (8…). |
nonce | Any unique string you have never used before. Single-use — a repeated nonce is rejected (replay protection). A timestamp + random suffix works well. |
expiry | Unix timestamp after which the order is dead. ~24 hours out works well; the order stays valid (covering any deposits) until then. |
signature | EIP-191 personal_sign over the canonical message below, signed with the private key of from. |
There is no amount field — you authorize the destination, and the desk fulfills whatever you send.
2.1 The canonical message (what you sign)
You sign this exact text — six lines, joined by single \n newlines, no trailing newline:
OTC Swap Order
version:2
from:<from>
to_xmr:<to_xmr>
nonce:<nonce>
expiry:<expiry>
The values are used verbatim as strings. In particular:
to_xmris case-sensitive — sign it exactly as your Monero wallet shows it.expiryis signed as the same string you put in the JSON.
A concrete signed message looks like:
OTC Swap Order
version:2
from:0x47949a2614080287F4f333f1419e0FB3e99fcF73
to_xmr:85FP5pAhMHtjJ7dHn5yCh9Rss1wHEGReAepzLDt55N5h6JEPyW7cFDRFmP4TTDSfZHJC2qHae9gfh8jAvDNozeGtFWVPjZt
nonce:1718600000-abc
expiry:1718603600
3. Signing scripts
You need code (a wallet UI can't reproduce this exact message). Pick your language.
Python (eth-account)
# pip install eth-account
import time, secrets, json
from eth_account import Account
from eth_account.messages import encode_defunct
PRIVATE_KEY = "0x…" # the private key of your `from` address
TO_XMR = "8…" # destination Monero address
acct = Account.from_key(PRIVATE_KEY)
nonce = f"{int(time.time())}-{secrets.token_hex(4)}"
expiry = int(time.time()) + 86400 # valid 24 hours
msg = "\n".join([
"OTC Swap Order", "version:2",
f"from:{acct.address}", f"to_xmr:{TO_XMR}",
f"nonce:{nonce}", f"expiry:{expiry}",
])
sig = Account.sign_message(encode_defunct(text=msg), acct.key).signature.hex()
print(json.dumps({
"from": acct.address, "to_xmr": TO_XMR,
"nonce": nonce, "expiry": expiry,
"signature": sig if sig.startswith("0x") else "0x" + sig,
}))
JavaScript (ethers v6)
// npm i ethers
import { Wallet } from "ethers";
const PRIVATE_KEY = "0x…";
const TO_XMR = "8…";
const w = new Wallet(PRIVATE_KEY);
const nonce = `${Math.floor(Date.now()/1000)}-${Math.random().toString(16).slice(2,10)}`;
const expiry = Math.floor(Date.now()/1000) + 86400;
const msg = [
"OTC Swap Order", "version:2",
`from:${w.address}`, `to_xmr:${TO_XMR}`,
`nonce:${nonce}`, `expiry:${expiry}`,
].join("\n");
const signature = await w.signMessage(msg); // EIP-191 personal_sign
console.log(JSON.stringify({ from: w.address, to_xmr: TO_XMR, nonce, expiry, signature }));
Both print the JSON object to paste into Signal.
4. Submitting over Signal
- Send the JSON (one message, the whole object) to the desk's Signal number.
- The desk replies:
✅ ACCEPTED order <nonce> — now send ANY amount of native USDC …— proceed to send.❌ REJECTED — <reason>.— fix and resend (a freshnonce). See §6.
- After it's accepted, send USDC on Arbitrum One — any amount within the limits (§5):
- to — the desk address, your ONLY destination:
0x912500ffadc00c6B84F0dA6ACD4433F67D973444 - from: the exact
fromaddress you signed. - asset: select USDC (native USDC / Circle) by name in your wallet.
- to — the desk address, your ONLY destination:
- Once the deposit confirms and the swap completes, the desk sends a receipt with the Monero txid.
You can send more than one deposit against the same accepted order (until it expires) — each is swapped to the destination you signed.
⚠️ Send only to the desk address above —
0x912500ffadc00c6B84F0dA6ACD4433F67D973444.The USDC token contract
0xaf88d065e77c8cC2239327C5EDb3A432268e5831is NOT a destination. It only identifies which asset to send — your wallet uses it to label the token "USDC." Never paste it into the "to"/recipient field: funds sent to the token contract are permanently lost.Send only native USDC on Arbitrum One. Bridged USDC, other chains, or other tokens may be lost.
5. Fees, rate, and limits
- Rate: floating. The desk buys at the realized market price when your deposit lands; you take that price (and any slippage). The receipt shows your all-in effective rate.
- Desk fee: negotiated per client — contact the desk for your rate. It is all-in and charged at the end: the desk's cut already accounts for the actual trading, network-transfer, and Monero-withdrawal costs of your order, and the desk never charges below those costs (so a very small order may pay a higher effective rate). Your receipt shows the full breakdown.
- Limits: each deposit must be 15 USDC – 50,000 USDC. A deposit below the minimum is treated as dust; a deposit above the maximum is held for manual review (not auto-swapped).
- Dust: if a swap leaves a tiny amount below the payout floor, it is credited to you and folded into your next swap — never pocketed.
6. Rejections & errors
| reply | cause | fix |
|---|---|---|
signature does not match the from address | the message you signed ≠ the canonical format, or signed with the wrong key | rebuild the message exactly (§2.1); sign with from's key |
from address is not in the client whitelist | the from address isn't registered with the desk | use a registered wallet, or ask the desk to add it |
order has expired | expiry already passed (or clock skew) | sign a new order with a fresh expiry |
duplicate nonce for this sender | that nonce was already used | use a new unique nonce |
to_xmr is not a Monero address | malformed destination | check the address (95 or 106 base58 chars, starts 4 or 8) |
If you send USDC without an accepted order on file from that address (none submitted, or it expired), the desk does not auto-send — it holds the funds and resolves manually. Message the desk.
7. Security model (why signed orders)
- The signature commits to from + destination + nonce + expiry together. Changing any one of them invalidates the signature. (The amount is deliberately not signed — you authorize the destination, not a figure, so a short or extra payment never gets stranded on a mismatch.)
- The desk only acts when an on-chain deposit from
frommatches a signed order fromfrom. The deposit proves you funded it; the signature proves you authorized that destination. - A leaked Signal session or a man-in-the-middle cannot redirect funds: they can't forge a signature for a different
to_xmrwithout your private key, and a captured order can't be re-submitted (single-usenonce, plusexpiry). - Because an accepted order is a standing authorization until it expires, use a from-address you control and keep
expiryreasonable (e.g. 24 h). To stop or change it, sign a new order (it supersedes the old one) or let it expire. - Keep your
fromprivate key safe. Anyone with it can sign orders and spend that wallet directly — the desk's security can't protect a compromised key.
This API is in limited release. Test with a small amount first. Questions → the desk on Signal.
Questions? Message the desk on Signal.