← Back to the signer

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)"
}
fieldmeaning
fromThe Arbitrum address you will send the USDC from. Must be one of your registered wallet's addresses.
to_xmrMonero address to receive the XMR. Standard (4…) or sub-address (8…).
nonceAny unique string you have never used before. Single-use — a repeated nonce is rejected (replay protection). A timestamp + random suffix works well.
expiryUnix timestamp after which the order is dead. ~24 hours out works well; the order stays valid (covering any deposits) until then.
signatureEIP-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:

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

  1. Send the JSON (one message, the whole object) to the desk's Signal number.
  2. The desk replies:
    • ✅ ACCEPTED order <nonce> — now send ANY amount of native USDC … — proceed to send.
    • ❌ REJECTED — <reason>. — fix and resend (a fresh nonce). See §6.
  3. 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 from address you signed.
    • asset: select USDC (native USDC / Circle) by name in your wallet.
  4. 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 above0x912500ffadc00c6B84F0dA6ACD4433F67D973444.

The USDC token contract 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 is 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


6. Rejections & errors

replycausefix
signature does not match the from addressthe message you signed ≠ the canonical format, or signed with the wrong keyrebuild the message exactly (§2.1); sign with from's key
from address is not in the client whitelistthe from address isn't registered with the deskuse a registered wallet, or ask the desk to add it
order has expiredexpiry already passed (or clock skew)sign a new order with a fresh expiry
duplicate nonce for this senderthat nonce was already useduse a new unique nonce
to_xmr is not a Monero addressmalformed destinationcheck 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)


This API is in limited release. Test with a small amount first. Questions → the desk on Signal.

Questions? Message the desk on Signal.