polycratia

Stuck withdrawals: wait, bump or cancel as a pure function

· 8 min read

A withdrawal that has sat unconfirmed for an hour is not in one state. It is in one of three: too early to touch, worth replacing at a higher fee, or past the point where the only useful move left is to cancel it and hand the money back. Code that models this as a single timeout has to pick one of those three, and it will be wrong about the other two.

I keep running into the same shape in payout systems. A job wakes up, loads everything that is sent, compares a timestamp against a constant, and rebroadcasts. Small constant: it replaces transactions that were about to confirm on their own, and pays the fee twice for nothing. Large constant: support answers the customer by hand hours before the code does anything. Either way the interesting part, what should happen to this one transaction right now, is smeared across a cron expression, a config value and a branch inside a worker that is also holding an RPC connection.

The fix is not a better constant. You want a decision, and the decision should be a value that a function returns.

Three outcomes need two thresholds#

One threshold gives you two answers. Three answers need two thresholds, plus a budget for how often you are willing to pay for a replacement.

python
from datetime import timedelta

from withdrawals import StuckPolicy

policy = StuckPolicy(
    bump_after=timedelta(minutes=10),
    cancel_after=timedelta(hours=2),
    bumps=3,
)

bump_after is where patience ends. cancel_after is where hope ends. bumps is how many replacements the transaction is worth before you stop paying for them.

The constructor refuses an incoherent pair:

python
StuckPolicy(bump_after=timedelta(hours=2), cancel_after=timedelta(minutes=10))
# ValueError: cancel_after cannot come before bump_after

That check earns its line. An inverted policy does not crash anything: with the cancel condition tested first, it quietly makes the bump path unreachable, and nobody notices until someone asks why no transaction has ever been replaced. The same constructor refuses a policy that allows fewer than one bump. A pipeline that never bumps is spelled by choosing cancel_after, not by hollowing out the middle action.

The decision is a value, not an effect#

python
from datetime import datetime, timedelta, timezone
from decimal import Decimal

from withdrawals import Action, WithdrawalRequest, review

sent_at = datetime(2026, 9, 14, 12, 0, tzinfo=timezone.utc)

request = (
    WithdrawalRequest(
        id="w-1041",
        amount=Decimal("25.00"),
        currency="USDC",
        destination="0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f",
    )
    .approve()
    .start_sending()
    .mark_sent("0xdeadbeef")
)

decision = review(
    request, sent_at=sent_at, policy=policy, now=sent_at + timedelta(minutes=5)
)

assert decision.action is Action.WAIT
assert decision.waited == timedelta(minutes=5)

review takes the request, the moment it went out, a policy and the current time, and returns a Recovery: an action, the reason behind it, and how long the transaction has waited. It opens no connection, reads no clock it was not given, and writes nothing down. now is optional and defaults to the real clock. Tests pass it, so every branch is reachable without sleeping.

The reason is not decoration. It is prose carrying the numbers that produced the decision: unconfirmed for this long, against that deadline. A Recovery has two readers. The dispatch reads action, and the person who gets paged at three in the morning reads reason. Both are served by the same value.

Dispatch then fits in one function with no policy in it:

python
from withdrawals import bump, return_funds

def act_on(request, *, sent_at, policy, holds, replacement):
    decision = review(request, sent_at=sent_at, policy=policy)
    if decision.is_wait:
        return request
    if decision.action is Action.BUMP:
        return bump(request, replacement)
    return return_funds(request, decision.reason, holds)

The list of replaced references is already the counter#

review counts bumps as len(request.replaced). There is no bump_count field, and I would not add one.

python
bumped = bump(request, "0xfeedface")

assert bumped.reference == "0xfeedface"
assert bumped.replaced == ("0xdeadbeef",)
assert bump(bumped, "0xfeedface") is bumped

You keep the replaced references anyway, for a reason that has nothing to do with counting: any one of them can be the one that gets mined. When a confirmation finally arrives carrying a reference that is no longer the current one, you still have to recognise it as yours. Once that list exists, a separate integer is a second source of truth: you can increment it without replacing anything, or replace without incrementing, and the two will disagree exactly when the transaction is already in trouble.

The same list makes the replacement step total. Bumping to the reference that is already current gives back the same request. Bumping back to one that has been replaced is a conflict, not a no-op:

python
bump(bumped, "0xdeadbeef")
# InvalidTransition: withdrawal w-1041 already replaced transaction 0xdeadbeef

And the request itself refuses to exist in a state where a reference is current and replaced at once, so the counter cannot be inflated by resubmitting the same hash.

Two budgets that run out independently#

Time and attempts are not the same resource, and either one running out means the same thing: stop.

python
policy = StuckPolicy(
    bump_after=timedelta(minutes=10), cancel_after=timedelta(hours=2), bumps=1
)

decision = review(
    bumped, sent_at=sent_at, policy=policy, now=sent_at + timedelta(minutes=30)
)

assert decision.action is Action.CANCEL

Thirty minutes is comfortably inside the two hours. This transaction is cancelled because the attempt budget is gone, not the time budget.

sent_at comes from the caller, because the package stores nothing and owns no clock. That makes the definition of "waited" a design decision at the call site, so it is worth saying out loud: I pin it to the first broadcast, not to the latest bump. Reset it on every bump and a transaction that gets bumped becomes immortal. Each action the system takes pushes its own deadline out, and the only requests that ever reach cancel_after are the ones nobody touched. The customer's clock does not restart when you rebroadcast.

Cancelling twice has to release nothing twice#

The cancel path is the one that moves money back, so it is the one that gets retried by a crash, a redelivered queue message, or an operator clicking twice.

python
failed = return_funds(request, "unconfirmed past its deadline", holds)
again = return_funds(failed, "unconfirmed past its deadline", holds)

assert again is failed
assert holds.held("w-1041") is None

Two things make that hold. Failing a request that has already failed with the same reason goes through the replay path and returns the identical value; failing it with a different reason raises InvalidTransition instead of quietly overwriting the first explanation, which is the one that is true. And releasing a hold that is not there reports that nothing was released rather than inventing a credit.

The packaged cancel puts the replacement in front of the stuck transaction and then returns the funds. Because bumping to an unchanged reference hands back the same request, running that sequence twice does not manufacture a second replacement or a second release.

What review refuses to answer#

Anything that is not a transaction waiting on the chain raises InvalidTransition. A request still in sending has no reference and an unknown outcome, so there is nothing to replace and nothing to measure. A confirmed or failed one is terminal.

That is deliberately a refusal and not a WAIT. WAIT means the chain still deserves the benefit of the doubt. A request that may never have reached the chain is a different problem with a different owner, usually reconciliation, and answering it with "wait" is how it stays invisible for a day.

What I would do differently#

In the payout systems I built before I wrote any of this down, the recovery rule lived inside the worker, and the first production surprise was the same every time: someone needs different patience for a different asset or a different rail, and the threshold is global. The signature here allows a policy per call, which I think is the right shape. It only takes the discipline not to add one module-level default policy and reintroduce the problem in a nicer font.

The other boundary I would keep exactly where it is: nothing in the decision function knows what the replacement should cost. Fee strategy belongs to the rail. review says only that a replacement is due, and leaves the caller to produce one.

The decision function is the smallest piece of a withdrawal pipeline, and the first one I would transplant into a new system. It lives in recovery.py in https://github.com/polycratia/withdrawals, next to the holds and the broadcast path it answers to.

react

$ new-project --brief

or email hey@polycratia.com