Skip to Content
EngineeringWithdrawal

Withdrawal and treasury

A mirror of the deposit path with one important difference: on deposit the backend signs nothing; on withdrawal it signs everything.

POST /withdrawals → 202 twelve checks, the reserve taken last ↓ row 'requested' Withdrawer.tick() claimRequested (tx_hash IS NULL) ↓ under lock 728_035_957_001 minFees → decimals → calldata → nonce('pending') → destFromBlock signTransaction → hash = keccak256(signed) → attachTx(TO THE DATABASE) → broadcast ↓ receipt status=1 settle: rates → withdrawal_sent → gas_spent → projectEvent(gas) → projectEvent(withdrawal) → markSent ↓ 'sent' DeliveryWatcher Transfer from the destination chain's vault to the user 'delivered' | 'partially_delivered'

Accepting the request

treasury/withdrawals.service.ts. The schema is packages/contracts/src/withdrawals.ts — exactly three fields: asset, amount, chainId.

toAddress is not declared, and that is decision W2

The recipient is always the SIWE sign-in address; the server substitutes user.walletAddress. The field is not “ignored” — it is not declared, so validation rejects the extra key.

The reason: a withdrawal is signed by the master key, which owns every user’s funds. With an arbitrary recipient, a stolen JWT would equal theft of the balance — one POST and the money is gone irreversibly. With the address taken from users, a stolen JWT gains nothing.

This is currently the primary security boundary of the withdrawal path.

Check order — cheapest first, the reserve last

CheckCodeHTTP
user existsUSER_NOT_FOUND404
a route exists for asset × chainIdROUTE_UNKNOWN404
digits ≤ the token’s decimalsAMOUNT_PRECISION422
scaled(amount) > 0AMOUNT_NOT_POSITIVE422
WITHDRAWAL_MIN_USD (skipped when there is no price)AMOUNT_TOO_SMALL422
a price must existPRICE_UNAVAILABLE503
WITHDRAWAL_MAX_USDAMOUNT_TOO_LARGE422
RPC configured and readableBRIDGE_UNAVAILABLE503
bridge limit ≥ amountBRIDGE_LIMIT409
gas read succeededGAS_CHECK_UNAVAILABLE503
the SCW can cover the relay feeGAS_UNAVAILABLE503
enough free balanceINSUFFICIENT_FUNDS409

What is not obvious about that order:

  • Precision comes before everything else. An amount that cannot be encoded in decimals would be truncated by parseUnits inside the sender — “while already holding the money reserved, with no owner left to release it”.
  • It checks the sign, not the threshold. WITHDRAWAL_MIN_USD does not close that hole and must not: it is skipped without a price and is zero in dev. Before this check a zero request travelled the entire path and burned the master key’s nonce, the relay fee and gas — without limit.
  • Minimum and maximum react differently to a missing price. The minimum is skipped — the cost of being wrong is a request worth pennies. The maximum must not be: it is the only limiter on the damage one request can do, and without a price a 10 BTC request would sail through.
  • “We do not know” ≠ “not allowed”: an unconfigured RPC and a network failure give 503, not 409. Confusing them shows a permanent refusal where the truth is temporary unavailability.
  • It catches SnapshotNotReadyError specifically, not a bare catch {} — which swallowed both “no price” and a typo in a currency code.

GET /withdrawals/routes degrades per route, not as a whole response: a Promise.all without try/catch would drop all eleven rows because of one unreachable chain.

The reserve

It is not a journal event: the journal is append-only and records what happened, and a request can still be rejected.

free balance = the :transit projection − withdrawal reserve (conversion_id IS NULL) − conversion reserve

ACTIVE = ('requested') — only this status holds money

  • sent is NOT active. withdrawal_sent is projected before the row becomes sent, so :transit is already reduced. Counting sent as active would subtract the amount twice, and the balance would go negative for the duration of the bridge transfer.
  • failed — no event reached the journal, the projection was never reduced, the money is free again.
  • delivered / partially_delivered — terminal.
  • needs_review_at stays reserved: it is set exactly when it is unknown whether the money left, and a reserve is the only state that is safe under both answers.

Taking the reserve

Two decisions:

  1. projectedFreeOf() is called BEFORE the transaction opens, and even before createQueryRunner(). Inside an open transaction, waiting on someone else’s query would hold the connection and demand a second from the pool — ten parallel withdrawals against a pool of ten would deadlock them all. Staleness is safe here: accruals only grow, so a projection read earlier can only be smaller than the current one, i.e. stricter.
  2. The lock is on the users row, not on the requests: FOR UPDATE over withdrawals with an empty result locks nobody — a lock on zero rows holds nothing. A row in users always exists.

scaled() scales to 18 digits, explicitly rejects exponential notation (1e-7 would break BigInt() with a bare SyntaxError → 500), and does not silently truncate a fraction longer than 18 digits — otherwise '1.0000000000000000006' would compare equal to '1' and a 1e-18 overdraft would pass.

Interaction with conversions (C21)

The conversion circle drives collateral to the swap wallet through the same machinery: it inserts a withdrawals row via insertInternal, but with a conversion_id. The differences:

  1. it does not check the free balance — the money is already reserved by the conversion (C14), and a second check would reject its own reserve as someone else’s;
  2. conversion_id excludes the row from the user’s reserve and from listForUser — showing it in the history would report a withdrawal the user never asked for;
  3. it accepts an arbitrary toAddress (S3) — constraint W2 must stay in force for user requests, and the bypass must be visible in the row itself.

Signing and sending

The master key signs — the only place in the project where it signs anything. The gas wallet cannot do it: the SCW’s owner is the master EOA, and execute from any other address is rejected by the contract with NotAuthorized.

The Derive account is a LightAccount-style proxy; the owner calls execute(address,uint256,bytes) directly, with no EntryPoint. executeBatch is used in its with-values form: the variant without values exists, but it has no way to pass the relay fee, and simulating such a batch reverts.

Why this is not in the request handler

The hash and the signed bytes are written to the database BEFORE broadcast. Otherwise a process crash between sending and recording produces a second withdrawal of the same money — the only scenario in this subsystem where a bug costs real money. That is the whole reason signing and sending are separate: sendTransaction would return the hash only after broadcasting.

Assembly

  • minFees is asked for here, not in advanceExisting: before the hash is recorded, an RPC failure cannot affect an already-sent request.
  • Decimals are taken from the Derive-side token, not from DEPOSIT_ROUTES: withdrawFromAppChain encodes the amount in that token’s scale. They match as of 2026-08-20, but that is a coincidence, not a guarantee — a future asset with diverging scales would send 1 BTC as 1e-10 BTC.
  • The transaction shape is chosen from the allowance actually read, not from a flag in the database: contract state is the truth, and a flag is a copy of it that diverges on the first manual intervention.
  • getNonce('pending'), not the confirmed one: several requests are sent back-to-back within one tick, and the second must see the first’s nonce before it is mined.
  • The destination chain’s block height is captured BEFORE sending: captured later, it would miss a fast delivery.

attachTx writes tx_hash, tx_nonce, dest_from_block, fee_native, signed_tx, attached_at in one statement:

  • attached_at = now() is computed by the database: application and database clocks can drift, and the stuck-request alert threshold is measured from this field.
  • fee_native is fixed as a fact rather than re-read during recovery: the relay fee floats, and gas_spent would record a charge the user never paid.
  • signed_tx is the only way to self-heal a lost broadcast: the same bytes give the same hash, so a rebroadcast is a no-op, not a second withdrawal. It contains no secret — a signed transaction is public from the moment it is signed.

Four layers against double-sending

  1. Write order: the hash reaches the database before the broadcast.
  2. The unique partial index withdrawals_tx_hash.
  3. claimRequested filters on tx_hash IS NULL.
  4. The advisory lock on the tick — needed because claimRequested/claimInFlight do not stamp a claim marker on the row. Two instances would take the same request between the claim and attachTx and sign two transactions with different nonces: a double withdrawal with no failure anywhere in the code, and one the unique index cannot catch.

Two queues

QueueFilterOrderLimit
claimRequestedtx_hash IS NULL AND needs_review_at IS NULLrequested_at5
claimInFlighttx_hash IS NOT NULL AND needs_review_at IS NULLrandom()50

Splitting them matters: without it, five stuck requests that already have a hash would occupy the entire limit forever and halt withdrawals for the whole platform. BATCH = 5 is a nonce constraint, not a performance one — requests are sent sequentially with a manual nonce.

ORDER BY random() — this is polling, not a queue. Sorting by age starves: fifty permanently stuck rows would hold every slot, and newer requests would never be polled at all — their transactions mine, but nobody calls settle for them, no event is written, and the reserve is held indefinitely.

All three claims are wrapped in a CTE: WITH claimed AS (UPDATE … RETURNING *) SELECT * FROM claimed. Two reasons: outside an explicit transaction the lock from SELECT … FOR UPDATE is released immediately; and TypeORM returns [rows, rowCount] for a bare UPDATE, which made .map(toRow) produce two “rows” out of undefined — “the request executed as a blank, the withdrawal never went out, and the log filled with заявка undefined”. Marked “do not simplify back”.

Delivery

treasury/delivery-watcher.ts. This is not a scanner: there are no cursors and no continuous walk over blocks; requests go out only while unfinished rows exist.

It reads Transfer from the destination chain’s vault to the recipient address — which is what the receiving side of the bridge unlocking funds looks like.

The window’s upper bound is the confirmed head

CONFIRMATIONS = {1: 12, 10: 30, 8453: 30, 42161: 240}.

The asymmetry with the deposit watcher is deliberate: that one appends an idempotent event per log, so anything seen on an unconfirmed head is fixable. markDelivered writes a final status — a request closed on a log from a block that later loses a reorg will never be reopened.

The numbers follow each chain’s binding constraint: Ethereum 12 (reorgs are real), OP Stack 30 (no reorgs; the constraint is replica lag behind the load balancer), Arbitrum 240 (the same minute at quarter-second blocks). No money is delayed by this: the request has long been sent and the posting is made — only the label on the screen is waiting.

The cursor advances only across a complete window

A window ending at the edge of what has been read may have come back empty not because there is no transfer in it, but because the replica that answered has not indexed those blocks yet. Advancing the cursor past such a window would permanently skip the range holding the delivery — and the request would stay sent forever, silently.

Three outcomes

Comparison is in whole token units, not decimal strings: '10' and '10.0' are equal as amounts and different as strings.

  • total < expectedpartially_delivered plus an error log. There is no automatic top-up (W5: it would require keys and gas in the destination chains). There is no compensating posting either: withdrawal_sent was recorded at the full amount and markDelivered writes nothing to the journal. The journal shows a larger withdrawal than actually arrived, and the difference is left for manual handling.
  • total = expecteddelivered.
  • total > expectednot a delivery error but an attribution anomaly: the total is summed over every log in the window, and the windows of two requests by one user to one address can overlap. Real attribution needs the bridge’s message id, which we do not read. The request is closed, but with a loud error: another request’s genuine partial may have been recorded as complete.

Recovery

The governing principle: a request’s fate is decided only by a receipt we hold in our hands, and only by our own receipt. A transport error means “we do not know”, not “it failed”. markFailed is called in exactly two cases: the hash is not yet recorded, or we hold our own receipt with status 0. Absence of evidence never releases the reserve.

Where it failedWho picks it upOutcome
Before attachTxclaimRequestedmarkFailed is permissible — the transaction physically could not have gone out
After attachTxclaimInFlightthe status is untouched; rebroadcast byte-for-byte
Receipt with status 0settlemarkFailed
Between projectEvent and markSentthe forbidden state, see below
sent with dest_from_block IS NULLDeliveryWatchererror, no advance: scanning from block 0 is not an option

Reading the node’s answers

  • already known is a successful self-heal and is swallowed silently. A node answers this only while the transaction is in the mempool.
  • nonce too low means nothing definite. As soon as our transaction lands in a block, this becomes the answer to rebroadcasting it — i.e. it is the normal post-mining response. On top of that, receipts are read through a load balancer, and a lagging replica returns null for a transaction that is in a block; both reads miss together, because replica lag is correlated within seconds.

escalateIfNonceLooksConsumed — two observations, not one

Escalation requires nonce too low and the confirmed nonce having passed row.txNonce and no receipt — twice in a row. The first observation is only a warning: replica lag lives for seconds while ticks are a minute apart.

The history is worth keeping: the method used to be called failIfNonceConsumed and called markFailed. The reserve was released on a transaction that had already sent the money — no event, the projection not reduced, the status not sent, so claimSent never sees the row, while the bridge delivers the transfer. The user sees the money both on screen and in their wallet.

needs_review — escalation WITHOUT a status change

needs_review_at = now(), error = reason, and the status stays requested. Marking such a request failed would release the reserve on money that may already be in flight — a double payment. The row drops out of both queues and is handled by hand.

A column, not a new status value: the status is read by the API contract, the frontend and the free-balance computation, and a new value would have to be classified correctly in each — one mistake in exactly one of them would release the reserve again. The flag is orthogonal to the status.

The gate against someone else’s receipt

On a revert, ethers 6 throws CALL_EXCEPTION and puts the receipt in error.receipt. But it also attaches receipt to TRANSACTION_REPLACED, where the receipt belongs to the replacement transaction. The gate is strictly code === 'CALL_EXCEPTION' && receipt.status === 0. Confusing them means recording withdrawal_sent under a hash that will never be mined.

Write order in settle — three rules, each bought with a regression

  1. Both FX rates are read BEFORE the first append. usdRateFor throws SnapshotNotReadyError when the showcase is cold. While the gas rate was read in place, an external call stood between projectEvent and markSent, and its failure left the request in the forbidden state: the projection already reduced while the row is still requested, so the same amount is subtracted by the reserve as well — the free balance understated twofold, possibly negative. And the state is permanent.
  2. gas_spent AND ITS PROJECTION go before the withdrawal_sent projection. Any database write can fail — a projection is one too — and a failure inside the “projected but not sent” window leaves the amount subtracted twice forever. Failing on the gas projection itself is, by contrast, safe: the row stays requested with its hash, claimInFlight picks it up next tick, append deduplicates on the key, and the projection replaces postings rather than adding to them.
  3. projectEvent strictly before markSent, with nothing that can fail in between. The reserve is released only once the money is visible in the projection.

Locked down by tests, one of which makes every operation in that window fail.

Postings: withdrawal_sent+external / −transit (the deposit’s mirror); gas_spent → both legs platform, with the link to the user living in the event’s user_id. Platform legs do not excuse skipping the projection: without projectEvent the event stays a log entry, not one row appears in ledger_entries, and the expense never reaches balances.

There are no events at all on delivered / partially_delivered. The journal considers a withdrawal to have happened at amount, not at delivered_amount.

Gas

Two methods at different stages of the tick.

fund(users) — dust onto HD addresses: floor 1e12, target 5e12 wei. It tops up to the target rather than adding a fixed amount; otherwise a balance would accumulate on the address that is never spent. A shortage on the gas wallet kills the whole pass with an exception: an empty gas wallet is an incident and must be visible. The expense is recognised at the moment of sending, not when the gas is actually burned.

fundOperational() — the master EOA and the SCW, with different thresholds:

AddressPays forFLOORTARGET
Master EOAL2 gas for execute2e131e14
SCWthe relay fee (msg.value)2e145e14

These are costs of different natures, sixty times apart: a withdrawal transaction is ~0.0000003 ETH against a relay fee of up to 0.000019 ETH. An averaged threshold once failed funding for both addresses over a shortfall of one thousandth of an ETH. A shortage here does not throw but accumulates in shortfalls: a throw on the first one (the master EOA goes first) used to abort the loop before the SCW.

Spending on operational transfers is not written to the journal: the recipient is a platform address and there is no user to attribute it to.

Not verified live

Not a single withdrawal transaction has ever been sent to any network. Three independent markers confirm it: “today, before the first deploy, such rows simply do not exist” (withdrawer.ts:453), “nothing has been deployed yet” (withdrawer.ts:414), and the SCW’s gas target, temporarily cut “for the first live run”.

Verified by reading contracts and by simulation: all 11 routes are live; execute from the owner passes eth_call and reverts with NotAuthorized from anyone else; decimals match on every asset; the SCW accepts a plain transfer (eth_estimateGas = 26,215).

executeBatch with approve and the fee needs a live check once per asset.