Swap USDC on Base for shielded ZEC with no custodian, no signer, no bond and no wrapped asset. The
USDC waits in escrow in ZecuredSwap and is released only by a zero-knowledge proof, made by
whoever paid, that a shielded payment for that very order is in the Zcash chain. It runs on the
same Zcash light client as the loans: ZcashHeaderRelay (Equihash verified on Base), ZIP 221
history commitments, RISC Zero guests and ZecuredVersions (docs/TIER2.md).
The trust assumptions are the relay's proof of work (the work an anchor needs grows with the order's size), RISC Zero's verifier, and the payment guest's correctness. The signers' only part is the version registry: a new payment guest takes all five and a public delay, and three can remove a broken one at once (D63).
- Buy ZEC (the on-ramp). The taker locks USDC; the market maker pays ZEC to the taker's Ironwood address and proves it. Any Ironwood address can receive: the taker needs no Zecured tool beyond the app's "Buy shielded ZEC" page.
- Sell ZEC (the off-ramp). The maker locks USDC; the taker pays ZEC to the maker and proves
it, from the CLI wallet (
zecured-wallet swap sell).
- Quote. The app asks the maker for a quote (
GET /swap/quote, through our backend's proxy, docs/INTERFACES.md § 4.3): aSwapQuotesigned under EIP-712 (ZecuredSwap1) that names the taker, so nobody else can take it. - Open. The app draws a salt and computes
C = keccak256(salt || receiver)locally, approves the USDC and callsopen(quote, signature, C)from the user's own wallet. The order id iskeccak256(abi.encode(chainid, swap, maker, nonce)), and the maker's nonce is spent. - Destination. The app hands the maker the salt and the receiver off-chain
(
POST /swap/orders/:id/destination); the maker checks them againstC. Base only ever seesC, and no authentication is needed: a receiver that does not matchCis refused. - Payment. The maker fixes the height of its proof's anchor, then, after a random delay,
pays the receiver in one Ironwood-only transaction, with
"ZSWP" || orderIdat the start of the note's memo and no outgoing viewing key (see "Privacy"). - Proof. Once the header at the anchor's height has the work the order's size requires on
top of it, the maker proves the payment under exactly that header with the payment guest and
calls
proveSettlementbefore the order's deadline: the settlement is pending. - Finalize. After
finalizeDepthheaders andfinalizeDelayseconds, with the anchor still on the relay's best chain and the version still active,finalizeSettlementpays the USDC to the maker, less the fee (none on testnet or in the beta). - Refund. If no valid proof stands when the deadline passes,
refundreturns the USDC to the taker. A proof only needs to arrive by the deadline while it stays valid: one whose anchor leaves the best chain after the deadline cannot be proven again (W-27, docs/MAINNET.md § 7); finalizing may come later.
A Sell runs the same way with the roles swapped: the maker locks the USDC at open and names its
own receiver commitment in the quote; the CLI checks the maker's receiver against it before it
opens, pays, proves and finalizes, and keeps an order file (0600) marked paid before the broadcast,
so a resumed run never pays twice. The CLI deals only with the ZecuredSwap of its pinned
deployment record, which script/DeploySwap.s.sol writes (for example
contracts/deployments/base-sepolia.swap.json): --swap-deployment or ZECURED_SWAP_DEPLOYMENT is
required, with no default, since a default path would be read relative to the working directory. It
refuses a quote that names another contract or chain, and a contract whose usdc(), relay() or
versions() is not the record's. It opens, and pays, only while its payment guest is a working
Swap version and the relay keeps up with the Zcash tip, and only while the payment's whole pipeline
fits before the order's deadline: the blocks to the planned anchor and the order's tier of work on
top of it (75 s each, the work estimated from the relay's last 24 headers), two blocks for building
the payment, and --prove-secs (proving and submitting, 7,200 s by default). It does not open an
order whose window could not hold that pipeline, and --min-window (10,800 s by default) must hold
the anchor window, the payment's build and --prove-secs. It anchors its proof and pays as the
maker does: the anchor's height fixed first, then a random delay before the payment, and a last
look at the chains right before the broadcast (see "Privacy").
The payment statement (tier2/payment-statement, guests payment-test and payment-main) proves:
- a v6 transaction of an epoch the build allows that is Ironwood-only: no transparent input or output and no Sapling or Orchard-pool bundle, even an empty one (D5), so no pool turnstile publishes a value that could tie the payment to the order's public amount;
- one of its Ironwood actions creates the V3 note the payer opens (receiver, value,
rseed): the action's ciphertext decrypts, under the ephemeral secret that note derives, to exactly that note, so its receiver finds it and can spend it; - the note's 512-byte memo starts with
"ZSWP" || orderId, so one note pays one order, even when two orders share a receiver; C = keccak256(salt || receiver);- the transaction is in a block committed, through the epoch's ZIP 221 history tree, by a later header (the anchor), with the deposit statement's epoch rules.
Its journal is abi.encode(uint8 kind = 1, uint32 branchId, bytes32 anchor, bytes32 orderId, bytes32 receiverCommitment, uint64 zatoshi), 192 bytes: no transaction id, note commitment,
nullifier or address. The contract checks the seal against an active Swap version and its branch
ids, the order id and the commitment against the order, a value at least the order's, and the
anchor's work for the order's size.
| Testnet (Base Sepolia) | Mainnet beta | |
|---|---|---|
| Pay window a quote may set | 2 to 6 hours | 6 to 24 hours |
| Finalization | 150 s and 6 headers | 1,800 s and 6 headers |
| Anchor work | the Intake's testnet tier | the Intake's mainnet tiers (by amount) |
| Branch ids | NU7 | NU6.3 and NU7 |
| Largest order | 200 USDC, 10 TAZ | 250 USDC, 5 ZEC |
| Protocol fee | 0 (at most 1 % by construction) | 0 |
| A new payment guest | 10 minutes after five signatures | 14 days after five signatures |
The configurations are contracts/config/swap-base-sepolia.json and
swap-base-mainnet-beta.json; script/DeploySwap.s.sol deploys them, and the lending deployment
can start the first Swap version (PAYMENT_IMAGE_ID).
| # | Attack | What stops it | Tests |
|---|---|---|---|
| 1 | One payment for two orders | The memo binds one order id | K4 memo changes; test_prove_aJournalOfAnotherOrder |
| 2 | A wrong receiver or too little | The commitment and the amount are checked | test_prove_wrongReceiverOrTooLittle |
| 3 | An Orchard-pool, Sapling or transparent part | D5 in the statement | K4 |
| 4 | A fake branch | Work tiers by amount, the readiness wait (K3), drops and stale-record replacement | test_prove_anchorAndWorkTiers, test_finalize_anchorReorgedOut, test_refund_afterAFakeProofIsDropped |
| 5 | A journal replayed on another order, contract or chain | The order id binds the chain, the contract, the maker and its nonce | test_orderId_formulaAndVector |
| 6 | A quote replayed | The nonce is spent at open; a quote naming a taker is that taker's |
test_open_replayOfAQuote, test_open_takerBinding |
| 7 | A proof at the deadline | A proof must come by the deadline; no refund while one stands | test_prove_byTheDeadline, test_refund_notWhileAProofStands |
| 8 | A broken guest | Three signers remove the Swap version at once; pending settlements under it go stale and are dropped (R-14); the payers stop quoting and paying (W-7) | test_finalize_versionRemoved; w7_refuses_to_pay_when_the_version_is_not_working |
| 9 | RISC Zero's emergency stop | Proofs revert; after the deadline the USDC goes back to whoever locked it (the payer's risk); the payers stop quoting and paying while it lasts (W-7) | test_refund_afterARouterEstop; w7_refuses_a_destination_when_the_router_is_stopped |
| 10 | Rounding | Exact zatoshi; the fee rounds down | — |
| 11 | Reentrancy | nonReentrant everywhere, SafeERC20, ERC-1271 checked by a static call |
test_reentrancy, test_open_erc1271MakerCannotMeddle |
| 12 | A leak through events | No event or calldata holds a Zcash identifier | test_events_carryNoZcashIdentifier; the leak scanner (docs/TESTING.md § 4) |
Four invariants run over a handler that opens, proves, reorganizes, drops, removes versions, finalizes, refunds and moves time: the contract's balance is what it holds locked, which is exactly its open orders' USDC; each unit leaves once; pending settlements exist only for open orders; and a settled or refunded order stays so (docs/INVARIANTS.md I36 to I39).
swap/maker (zecured-swap-maker) quotes from a price feed plus a spread within its inventory and
the order caps, follows the OrderOpened events of its own address, pays, proves, submits and
finalizes, drops stale records and proves again, and after a deadline refunds its own Sell orders
and the Buy orders it did not pay (the USDC goes back to whoever locked it). It keeps one unmined
payment at a time, decides each payment again on what the chains say right before broadcasting it
(the Zcash tip, Base's clock, the relay and the Swap version: a payment that could no longer land
below its planned anchor, whose proof could not land or that is too late is dropped unsent), and
records its opening before the broadcast. A proof runs in a task of its own, one at a time, so the
engine's passes, and the view of the chains the API decides on, never wait for one. It refuses to
start when its pay window is shorter than twice its worst-case pipeline, when its guest was never
scheduled as a Swap version, or when its chain does not match the RPC; a config still naming the
old prove_delay_max_blocks is refused with a message naming what replaced it.
It quotes, takes a destination and pays only while a proof could land: its guest's Swap version
is working (ZecuredVersions.isWorking: active, its router selector routed and not stopped) and
the relay keeps up with the Zcash tip, as read in a recent pass (and, for a payment, again right
before its broadcast); a removed version or a router emergency stop therefore stops new payments at
once, while the maker keeps finalizing and refunding. It refuses a quote while the price is stale, a
destination when too little of the window is left, and a payment whose anchor window and the
order's tier of work would not fit before a known network upgrade activation (a payment and its
anchor must share an epoch). The same destination given again once taken is acknowledged from the
order book, whatever the maker's view of the chains.
It has max_open_orders places, one taker at most max_open_orders_per_taker of them. A quote
takes one, with its ZEC (Buy) or USDC (Sell), from the moment it is given until its order is read
from Base or it can no longer be opened; a quote naming a taker counts toward that taker's cap. An
order holds its place while open, Sell orders included (they bound the USDC the maker locks), but a
Buy order still waiting for its destination only during its reservation: reservation_secs (10
minutes) from when the maker reads it, given only within the cap of its taker on Base (orders
opened from open Buy quotes included) and while the maker has a place and the ZEC. The reservation
keeps the order's ZEC for its destination; an order left without one holds nothing afterwards, so
orders opened and left tie nothing up for long, and a taker's order beyond its cap holds no place
and takes no destination. A destination for an order that no longer holds a place takes one again
only if one is free and the maker can pay it.
It pays an order a second time only when its wallet's scan shows, three blocks past the first
payment's expiry height, that the payment's input notes are still unspent: a payment it has seen
mined never counts as expired, and a light wallet server's transport error leaves the order for the
next pass. It never logs a receiver, salt, transaction id, key or IP, nor the height of a payment's
block next to its order, and erases them once an order is done. In the mainnet beta it quotes only
to holders of an invite code (X-Zecured-Invite; only the codes' hashes are configured).
On a swap, the taker's Base account and the order's amount are public, and where the ZEC went is not: Base holds only the receiver's salted commitment, and the payment carries no outgoing viewing key (threat model L26).
The proof's anchor header is public (the journal's anchor); the payment's block is not, and the
anchor's height does not depend on it. When a Buy order's destination reaches the maker (for the
CLI, when its Sell order lands on Base), the payer takes c, the height of the Zcash block then
being mined, draws r uniformly in 0 to pay_delay_max_blocks once for the order (kept in its
order book or order file, across restarts), and fixes the anchor at
a = c + pay_delay_max_blocks + 1 + 2 + r. Only then does it pay, after a random delay of 1 to
pay_delay_max_blocks blocks (at least 2 outside a local fork, where 0 counts as 1 for the plan; 8
for the testnet maker and by default in the CLI): the payment lands in one of the blocks c + 1 to
c + pay_delay_max_blocks (uniformly, plus any wait in the mempool), below a. The two blocks of
headroom are for building the payment (the CLI scans its wallet, both payers prove Halo 2): the
payer decides to pay once its delay is over, builds the payment, then decides again on the chains
as they are right before the broadcast, and does not send a payment that could no longer land below
a. The payer proves under the header at exactly a, once the order's tier of work is on top of
it. Base thus shows the order's opening, which puts c within a block or so, the anchor, and that
the payment's block is below the anchor in the same epoch: the payment hides among the
pay_delay_max_blocks blocks after c (8 on testnet; a block or two more when it was
slow to build), and the anchor says nothing about which. There is no separate proving delay any
more: the draw r does that work, and a delay after the anchor would hide nothing. The time a
proof reaches Base follows the anchor's work and the proving time, not the payment.
Two cases fall outside that set. A payment mined at or after a (late: one that waited long in
the mempool) anchors at h + 1 + r', h its block and r' a draw of its own in 0 to
pay_delay_max_blocks, made when the payment is first seen late and kept (the order's r would tie
the late anchor to the plan); a payment held back before it is sent (another payment in flight, a
stopped verifier, a tip that moved past the plan while it was built) until it could no longer land
below a is planned again from the current height instead, with the same r, and so is one made
again after it expired unmined. And an anchor never leaves the payment's epoch: near a known network
upgrade activation the payer does not pay at all, a late anchor is cut at the epoch's last height,
and a payment mined in an epoch's very last block cannot be proven.
- No maker stake. A maker who never pays costs its taker only time: the USDC comes back after the window. A stake needs a provable delivery of the receiver (docs/MAINNET.md § 4).
- Payer risk. A router emergency stop or a version removal during the window keeps the payer's
proof out; the order then refunds to whoever locked the USDC. Both payers check that the version
works right before they broadcast a payment (once it is built), so only a stop between a payment
and its proof costs one. The CLI asks for a window of at least three hours (
--min-window, its privacy delays included) and pays only while the payment's whole pipeline fits before the deadline, and the contract bounds every quote's window. - USDC's blocklist. A blocklisted counterparty cannot be paid: its order's USDC stays in the contract.
- Testnet first. The payment fixture is synthetic (the disposable test vault's real deposit transaction with one action re-encrypted as a payment, re-anchored under a synthetic header) until a rehearsal payment gives a real one. The maker and the witness builder follow the configured Zcash network (D81); payment-main, with real seals, as an active Swap version comes before the swap opens on mainnet.