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
| Check | Code | HTTP |
|---|---|---|
| user exists | USER_NOT_FOUND | 404 |
a route exists for asset × chainId | ROUTE_UNKNOWN | 404 |
digits ≤ the token’s decimals | AMOUNT_PRECISION | 422 |
scaled(amount) > 0 | AMOUNT_NOT_POSITIVE | 422 |
≥ WITHDRAWAL_MIN_USD (skipped when there is no price) | AMOUNT_TOO_SMALL | 422 |
| a price must exist | PRICE_UNAVAILABLE | 503 |
≤ WITHDRAWAL_MAX_USD | AMOUNT_TOO_LARGE | 422 |
| RPC configured and readable | BRIDGE_UNAVAILABLE | 503 |
| bridge limit ≥ amount | BRIDGE_LIMIT | 409 |
| gas read succeeded | GAS_CHECK_UNAVAILABLE | 503 |
| the SCW can cover the relay fee | GAS_UNAVAILABLE | 503 |
| enough free balance | INSUFFICIENT_FUNDS | 409 |
What is not obvious about that order:
- Precision comes before everything else. An amount that cannot be encoded in
decimalswould be truncated byparseUnitsinside the sender — “while already holding the money reserved, with no owner left to release it”. - It checks the sign, not the threshold.
WITHDRAWAL_MIN_USDdoes 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
SnapshotNotReadyErrorspecifically, not a barecatch {}— 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 reserveACTIVE = ('requested') — only this status holds money
sentis NOT active.withdrawal_sentis projected before the row becomessent, so:transitis already reduced. Countingsentas 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_atstays 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:
projectedFreeOf()is called BEFORE the transaction opens, and even beforecreateQueryRunner(). 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.- The lock is on the
usersrow, not on the requests:FOR UPDATEoverwithdrawalswith an empty result locks nobody — a lock on zero rows holds nothing. A row inusersalways 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:
- 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;
conversion_idexcludes the row from the user’s reserve and fromlistForUser— showing it in the history would report a withdrawal the user never asked for;- 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
minFeesis asked for here, not inadvanceExisting: 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:withdrawFromAppChainencodes 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_nativeis fixed as a fact rather than re-read during recovery: the relay fee floats, andgas_spentwould record a charge the user never paid.signed_txis 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
- Write order: the hash reaches the database before the broadcast.
- The unique partial index
withdrawals_tx_hash. claimRequestedfilters ontx_hash IS NULL.- The advisory lock on the tick — needed because
claimRequested/claimInFlightdo not stamp a claim marker on the row. Two instances would take the same request between the claim andattachTxand 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
| Queue | Filter | Order | Limit |
|---|---|---|---|
claimRequested | tx_hash IS NULL AND needs_review_at IS NULL | requested_at | 5 |
claimInFlight | tx_hash IS NOT NULL AND needs_review_at IS NULL | random() | 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 < expected→partially_deliveredplus 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_sentwas recorded at the full amount andmarkDeliveredwrites nothing to the journal. The journal shows a larger withdrawal than actually arrived, and the difference is left for manual handling.total = expected→delivered.total > expected→ not 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 failed | Who picks it up | Outcome |
|---|---|---|
Before attachTx | claimRequested | markFailed is permissible — the transaction physically could not have gone out |
After attachTx | claimInFlight | the status is untouched; rebroadcast byte-for-byte |
Receipt with status 0 | settle | markFailed |
Between projectEvent and markSent | — | the forbidden state, see below |
sent with dest_from_block IS NULL | DeliveryWatcher | error, no advance: scanning from block 0 is not an option |
Reading the node’s answers
already knownis a successful self-heal and is swallowed silently. A node answers this only while the transaction is in the mempool.nonce too lowmeans 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 returnsnullfor 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
- Both FX rates are read BEFORE the first
append.usdRateForthrowsSnapshotNotReadyErrorwhen the showcase is cold. While the gas rate was read in place, an external call stood betweenprojectEventandmarkSent, and its failure left the request in the forbidden state: the projection already reduced while the row is stillrequested, so the same amount is subtracted by the reserve as well — the free balance understated twofold, possibly negative. And the state is permanent. gas_spentAND ITS PROJECTION go before thewithdrawal_sentprojection. 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 staysrequestedwith its hash,claimInFlightpicks it up next tick,appenddeduplicates on the key, and the projection replaces postings rather than adding to them.projectEventstrictly beforemarkSent, 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:
| Address | Pays for | FLOOR | TARGET |
|---|---|---|---|
| Master EOA | L2 gas for execute | 2e13 | 1e14 |
| SCW | the relay fee (msg.value) | 2e14 | 5e14 |
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.