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. Any address you control — you authorize it by signing with its private key. |
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 bot on Signal: @veil_bot.89.
- 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 native USDC on Arbitrum One. If you send a different token, bridged USDC, or on another chain, the swap simply won't auto-process — message the desk (@veilotc.10) and it's manually refunded.
4.1 Programmatic responses (JSON blocks)
For automated integrations, every reply ends with a single-line JSON object — the last line of the message. Parse that last line as JSON. Every block carries a stable, desk-generated swap_id that is identical across every message about the same swap, so you can correlate the whole lifecycle. The line(s) above it are human-readable; ignore them if you only want structured data.
Common fields (present on every block):
| field | type | meaning |
|---|---|---|
swap_id | string | desk-generated id, e.g. VEIL-3F9A2B7C — identical across all messages for this swap |
status | string | lifecycle stage (below) |
rail | string | usdc or btc |
ts | int | unix timestamp of this message |
status values: accepted → received → sent → complete (happy path), plus rejected and failed. (BTC adds forwarding → bridging → bridged between received and sent.)
accepted — order accepted; echoes your order_nonce once so you can map your request → swap_id:
{"swap_id":"VEIL-3F9A2B7C","status":"accepted","rail":"usdc","ts":1781889000,"order_nonce":"1718600000-abc","from_addr":"0x0463f1…","deposit_address":"0x9125…3444","to_xmr":"84Eiw…","min_usdc":50,"max_usdc":250000,"expiry_ts":1781892600}
received — your deposit landed; the swap is underway:
{"swap_id":"VEIL-3F9A2B7C","status":"received","rail":"usdc","ts":1781889100,"deposit_usdc":250.0,"to_xmr":"84Eiw…"}
sent — XMR dispatched to your address:
{"swap_id":"VEIL-3F9A2B7C","status":"sent","rail":"usdc","ts":1781889900,"xmr_sent":0.804512,"to_xmr":"84Eiw…"}
complete — delivered; the final receipt. all_in_rate = deposit_usdc / xmr_delivered (your effective price) is the only rate reported — there is no per-cost breakdown:
{"swap_id":"VEIL-3F9A2B7C","status":"complete","rail":"usdc","ts":1781890000,"deposit_usdc":250.0,"xmr_delivered":0.804512,"all_in_rate":310.75,"monero_txid":"213e6f6a…","to_xmr":"84Eiw…"}
rejected — submission refused; echoes order_nonce (when parseable) + a reason:
{"swap_id":"VEIL-3F9A2B7C","status":"rejected","rail":"usdc","ts":1781889000,"order_nonce":"1718600000-abc","reason":"order expired"}
failed carries an error string for manual follow-up. Field set is forward-compatible — treat unknown fields as optional and key off status.
BTC → XMR (in private testing — confirm with the desk before sending any BTC) uses the same shape with "rail":"btc": send your XMR address to the bot (no signing) → you get an accepted block whose deposit_address is a BTC address; receipts use btc_amount in place of deposit_usdc.
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: deposits of 50 USDC – 250,000 USDC are swapped automatically. Below 50 USDC is under the desk minimum and is not auto-processed (contact the desk); above 250,000 simply isn't automated — message the desk and it's processed by hand (no review, no hold — just not automatic).
- 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 |
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. Submit orders to the bot @veil_bot.89; questions or issues → message @veilotc.10 on Signal.
Questions? Message the desk on Signal.