Boots the Lelantos stack (Postgres, Anvil, a static price oracle and seven backend services) with [testcontainers-node][tc] and exercises deposit, shielded transfer, withdraw, swap and FMD-driven sync end to end. The test process owns the container lifecycle, so failures surface as host-side stack traces and adding logging needs no image rebuild.
Files run serially in path order, so tests/ precedes tests/edge/ and
tests/negative/.
| Spec | Cases | Covers |
|---|---|---|
| batch-flush | 1 | Four deposits submitted in parallel, drained by the relayer into a single flushBatch operation. |
| bundler-mixed | 7 | Holds the relayer's batcher to build bundles on purpose: a flush, transfer, withdraw, native withdraw and swap in one Bundler.execute, each with its own leaves, payouts and explorer row; a flush cancelled while held is dropped and the rest still bundle; no transaction exceeds bundle_max_items; a double spend is refused at enqueue; a proof bound to this relayer's Bundler reverts BadRelayer through another; a fresh wallet recovers the mixed bundle. Two relayers is a todo. |
| client-resync | 2 | Two deposits and two transfers, then a wallet built from the key alone reconstructs the recipient's balance from the chunk feed. |
| consolidate | 5 | Six notes against a 4-in circuit: a transfer needing more inputs is refused as INSUFFICIENT_COVER, naming the notes to merge and spending nothing, and autoConsolidate then merges them with a self-spend and lands the transfer — bob credited exactly, alice down the amount plus the relayer fee of both spends, the merged note created and consumed. |
| denominated-withdraw | 5 | A supplied withdrawal ladder: the preview reports the rungs and flags an amount between them, a withdrawal publishes the denomination exactly, and the change is split back onto the ladder. The only spec that exercises denominations — the built-in ladders are keyed by mainnet addresses, so this stack's mock tokens have none. |
| deposit-fee-asset | 16 | An mDAI deposit paying the relayer in WETH, through both the Permit2 witness and AllowanceTransfer strategies: each token debited exactly, the window state each strategy implies, the escrow flushed, wallet.awaitDeposit reporting its leaf as seen, the relayer holding a WETH note and nothing in mDAI, the depositor one mDAI note. The allowance window is opened by wallet.setupDepositAllowance — one signature and one permit for both tokens. An unflushed two-token escrow refunds each token through the raw ABI and wallet.cancelDeposit. The SDK refuses a native deposit with another fee asset and a yield fee asset. |
| deposit-fee-pairs | 24 | A property over every ordered pair of distinct assets (deposit A, fee paid in B): exact debits of each token at its own scale, the escrow binding B as the fee asset, the SDK's pulled/fees report, the relayer paid in B only, the depositor credited in A only, and the protocol fee accruing in A only. Pairs with mWBTC (8 decimals, scale 1) separate a fee converted at the wrong scale by ten orders of magnitude. |
| deposit-native | 4 | deposit({ native: true }) through NativeAdapter: coin spent rather than WETH, pool credited, no residue on the adapter, fee accrued, resulting note spendable. Skipped without a native adapter. |
| double-spend | 3 | A note is deposited and spent, then replayed from a stale wallet and rejected. |
| full-flow | 6 | Deposit, shielded transfer with change, withdraw, treasury fee accrual on both legs, and recovery of the recipient's balance by a fresh client. Includes a Permit2 maxTotal revert. |
| multi-asset | 5 | Deposits and withdrawals across WETH and mDAI, with per-asset fee accrual and net-of-fee recipient amounts. |
| shielded-fee | 1 | The committed relayer fee address and viewing key are the ones its nsk derives. Local: the only spec that needs no stack. |
| spendable-max | 6 | What the wallet says it can spend against what it can. Five notes and a 4-in circuit: balance() splits them into the four a spend can reach and the fifth it cannot, and the split adds up; spendableMax({ kind }) holds back exactly the fee the transfer is then charged, a transfer of that maximum leaves precisely the withheld note, and one unit more is refused as INSUFFICIENT_COVER with five notes and INSUFFICIENT_BALANCE with one. The cooldown rule is exercised by naming a window wider than the change note's age — the whole balance shows under withheld.cooldown and a spend is refused NOTES_HELD — and mining past it releases the note, which then spends. |
| swap | 4 | Quote resolution against the allowlisted UniV3 adapter and a shielded swap producing a note in the output asset; refuses a non-allowlisted adapter and one that under-delivers against minOut. Skipped without SWAP_WRAPPER_ADDRESS. |
| two-input-merge | 2 | A transfer consuming two input notes and producing one output. |
| wallet-restart | 6 | One note store, tree and spent set shared by a disposed wallet and its successor. The successor has the balance before it syncs, its first sync fetches no note and refolds no leaf where a cold wallet on the same key reads the whole feed, and it spends against the tree it inherited. Then a spend whose answer never comes back — the request is forwarded verbatim and the attempt failed — raises SPEND_OUTCOME_UNKNOWN and reserves its inputs rather than spending them; the reservation is still there after another restart, and a sync once the operation lands settles the notes as spent. |
| withdraw-native | 3 | withdraw({ native: true }) unwraps through NativeAdapter: recipient receives coin with no WETH movement, fees accrue in WETH. |
| yield-venue-liquidity | 3 | Where a yield withdrawal's tokens come from: a payout the idle buffer covers leaves the venue untouched, one it does not redeems the shortfall plus the buffer refill in one draw, and against a liquidity-capped vault the refill is best-effort while the payout is not. |
| yield-withdraw | 6 | A yield-asset withdrawal after the venue earns: paid at the accrued rate net of the unshield fee, the shortfall drawn from the venue, the performance fee charged on the gain only, then swept to the treasury out of its own units. |
| edge/concurrent-spends | 2 | Two parallel spends of the same note. From one wallet, the note is leased to one spend and the other is refused locally as NOTES_HELD. From two wallets on the same key, which share no lease, the relayer's nullifier guard refuses the loser: exactly one lands on chain, the recipient is credited once and the relayer paid once. |
| edge/submit-retry | 3 | Idempotent submit and unknown outcome, staged by holding the relayer's batcher. A submit the SDK retries under one Idempotency-Key queues exactly one operation however many attempts it made, lands once and pays one fee; the same key over a different submission is refused 409; and a submit every attempt of which times out raises SPEND_OUTCOME_UNKNOWN, leaving its inputs reserved rather than spent until a sync reconciles them against the operation that landed anyway. |
| negative/deposit-fee-too-low | 3 | A fee leaf that does not pay the relayer — addressed elsewhere, or worth nothing — leaves the deposit escrowed and out of the tree while a paying deposit flushes, and the payer reclaims it with cancelDeposit — one of them through wallet.cancelDeposit({ depositId, fromBlock }), which rebuilds the escrow from the pool's log. Also pins liveness: a paying deposit queued behind a full batch window of skipped ones still flushes. |
| negative/expired-permit | 1 | Deposit reverts when the Permit2 deadline has passed. |
| negative/relayer-admission | 6 + 2 todo | One real, never-submitted spend payload, replayed tampered under fresh idempotency keys: an unserved chain id, a proof bound to another relayer, a nullifier repeated within the payload, a field element at the BN254 modulus, a proof that does not verify, and a body over the 256 KB limit. Every case is refused before the batcher, leaving the relayer's signer balance and nonce untouched and its queue unchanged. The fee-note and missing-native-adapter cases are todo: neither is reachable from a client on this stack. |
| negative/zero-value-deposit | 1 | A zero-amount deposit is refused by the wallet, before any signature or chain call. |