polycratia

A withdrawal is a queue entry before it is a transfer

· 8 min read

Most withdrawal code collapses two different events into one function call: the user asking for money to leave, and the money actually leaving. Once those are the same operation, everything you might reasonably want to do in between — hold the request for review, batch it with others going to the same chain, re-price it when the fee market moves, cancel it because the destination has a typo in it — has nowhere to live. The fix is not a smarter send function. It is to make the request its own record with an explicit state machine, and to demote the broadcast to one transition inside it.

The shape that causes this#

The version I keep finding in custodial systems is a single handler. It validates the destination, debits the user's balance, calls the node, stores the transaction hash on the user's withdrawal row, and returns. It reads fine. It is short. It has no safe moments in it at all.

The first thing you lose is cancellation. There is no interval during which the operation exists but the money has not moved, so "cancel" is either impossible or it is a support ticket that ends with a manual transfer back. The second thing you lose is batching: to batch you need a population of pending requests, and in this design a request is never pending — it is either absent or already broadcast. The third thing you lose is pricing control. The fee gets decided at the instant a user pressed a button, by whoever happened to press it, rather than at the instant you were ready to send.

The fourth loss is the one that hurts during an incident. When the node call times out, you have no idea what happened, and the only place to look is the chain. So you write a reconciler that reads the mempool and tries to infer your own intent from someone else's data structure. You are asking the network what you decided.

Every transition is a decision someone can make#

The alternative is boring in the best way. A withdrawal is a record with a state, and the states are named after the decisions that produce them. In withdrawals the path is requested -> approved -> sending -> sent -> confirmed, with rejected and failed as the two ways out.

python
from decimal import Decimal

from withdrawals import WithdrawalRequest, WithdrawalState

request = WithdrawalRequest(
    id="w-1041",
    amount=Decimal("25.00"),
    currency="USDC",
    destination="0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f",
)

approved = request.approve()
sending = approved.start_sending()
sent = sending.mark_sent("0xdeadbeef")
confirmed = sent.confirm()

assert confirmed.state is WithdrawalState.CONFIRMED
assert confirmed.is_terminal

Four calls, four values. That is the entire point: there are now four moments where the request exists and something can be done to it, instead of one moment where it either worked or did not.

Two details in that snippet matter more than they look. A request is an immutable value — a transition returns a new request and the caller decides where to store it. That keeps the state machine out of your persistence layer, and it means the natural way to store a withdrawal is one row per version, appended, rather than one row mutated in place until the history is gone. When someone asks in three months why a payout went out at that fee, the answer is a sequence of rows, not a guess.

The other detail is rejected. It is reachable only while the money has not moved. That restriction is the state machine's real content. It draws a line through the lifecycle: on one side the operation is a piece of data you own and can freely change your mind about, on the other it is an event on a network that does not care what you decided afterwards. A state machine that lets you reject a broadcast withdrawal is not modelling a withdrawal, it is modelling a wish.

The cancellable prefix is where the policy lives#

Once the prefix exists, things that were architecture problems become ordinary queries.

python
def ready_to_send(requests):
    return [r for r in requests if r.state is WithdrawalState.APPROVED]

That is a population. Batching is a grouping over it. Fee estimation is a function of it, evaluated at the moment you are actually about to sign rather than at the moment of the request. A four-eyes approval rule is a guard on one transition instead of a feature bolted across a handler. A daily limit is a predicate over requests in the prefix plus requests already past it — and, importantly, both are visible, because a request that has not been sent yet is still a real object with an amount on it.

Routing belongs here too. A withdrawal whose destination is an address you also custody does not need a chain at all; it is an internal transfer between two accounts you control, and paying a network fee to move it is a donation. But you can only make that choice while nothing has been broadcast. In a design where the request is the transfer, the routing decision has already been made for you by the shape of the code — badly, and always in the expensive direction.

The repository is honest about where it stands: the request and its state machine are in place, the routing and the senders are not. That ordering is deliberate. The senders are the part everyone writes first and the part that is least interesting, because a sender is just an adapter over a node. The state machine is what decides whether a sender is even reachable, and whether you have anywhere to stand when you need to stop sending.

Idempotency becomes a property of the value#

Callbacks arrive twice. Node RPCs time out after the node did the work. Any queue you put in front of the sender is at-least-once, and if it claims otherwise it is lying about a network partition it has not met yet. So the interesting question is not how to avoid duplicate delivery but what a duplicate does when it lands.

python
assert sent.mark_sent("0xdeadbeef") is sent

Replaying a transition that already happened with the same data returns the same value. The second callback costs nothing, and the handler that processes it does not need to know it was second. That is worth more than a dedup table, because a dedup table has a window and a window is a bet about how late a retry can be.

The conflicting case is the one worth being loud about:

python
from withdrawals import InvalidTransition

try:
    sent.mark_sent("0xfeedface")
except InvalidTransition:
    # two hashes for one request: a double broadcast or a replacement
    # we did not record. This is an incident, not a race to smooth over.
    raise

A replay carrying different data is a conflict, not a silent overwrite. If you let the last writer win, you have quietly decided that a second transaction hash for the same request is a normal event, and you will find out otherwise during reconciliation, at the worst possible moment, with the chain as your only witness. The same rule covers any step the machine does not allow: confirming something that was never sent is not a slightly out-of-order message, it is a bug in whatever produced it.

What I would do differently#

I have built custodial wallets and an on-chain payment system, and stablecoin rails that run daily, and I have got this wrong in a couple of directions before settling here.

I would persist every version rather than the latest state. The immutable value makes this nearly free, and it converts "why did this go out twice" from forensics into a select.

I would decide the fee at the sending transition and never at request time. The gap between those two moments is exactly the window the request was created to give you.

I would reserve the user's balance when the request is created, not when it is broadcast. A queue that does not reserve lets the same balance be spent twice while it waits, and the second spend will look perfectly valid to every check you have.

And I would keep the terminal set explicit — is_terminal rather than a set of state names copied into three different retry loops. Reapers, retries and alerting all need the same answer to "is this still ours to touch", and they should get it from the same place.

Close#

A withdrawal is a queue entry that sometimes becomes a transfer. The broadcast is the shortest and least controllable step in it, and it is the only step most implementations model. Give the request a record and a state machine, and cancellation, batching, routing and pricing stop being features you have to retrofit — they are just things you can do to a row that has not left yet.

react

$ new-project --brief

or email hey@polycratia.com