polycratia

Routing a withdrawal is a value, not an if inside the sender

· 9 min read

A withdrawal to an address you already custody does not have to touch a chain: crediting it is a ledger move, with no fee, no confirmations and nothing to wait for. A withdrawal to anyone else is a transaction you cannot take back. In most payout code I have been handed, the choice between those two lives inside the sender, as an if a few lines above the broadcast, which means that after the fact nobody can say which path a given withdrawal took, or why.

That is the whole problem. The branch itself is trivial. What is not trivial is that the branch decides the fee and the latency, and it also decides what the word settled means, and then throws that decision away.

The two rails do not disagree about states, they disagree about settlement#

In the withdrawal pipeline I keep at https://github.com/polycratia/withdrawals, both rails walk the same state chart: requested, approved, sending, sent, confirmed, with rejected and failed as the two ways out. I did not fork the machine per rail, and I would push back hard on anyone who wanted to. What differs between the rails is not the shape of the lifecycle: it is what the reference on the request means, and when the request is allowed to confirm. On an internal route, the thing that moved is a ledger entry against an account I own, and it is done once the credit is booked. On an external route, sent means a transaction exists somewhere and confirmed means the chain agreed to keep it.

A boolean living in a local variable inside the sender cannot carry that. The settlement rule has to travel with the withdrawal, because the code that decides whether to wait for confirmations runs minutes or hours after the code that picked the rail.

Decide once, return the decision, log it before you hand it back#

So routing is its own function, and it returns a value:

python
def route(request: WithdrawalRequest, custody: Custody) -> Route:
    """Decide which rail `request` travels on and log the decision."""
    account = custody.account_for(request.destination)
    if account is None:
        decision = Route(
            rail=Rail.EXTERNAL,
            destination=request.destination,
            reason="destination is not an address we custody",
        )
    else:
        decision = Route(
            rail=Rail.INTERNAL,
            destination=request.destination,
            reason=f"destination is custodied by account {account}",
            account=account,
        )
    logger.info(
        "withdrawal %s routed %s to %s: %s",
        request.id,
        decision.rail,
        decision.destination,
        decision.reason,
    )
    return decision

Two details in there are load-bearing, and the if is not one of them.

The first is reason. The rail alone tells you what happened; the reason tells you what the system believed at the time. Months later, when someone asks why a payout to a known customer address cost a network fee, the answer you need is not "it went external", since the fee already told you that. The answer you need is "custody did not recognise that address at 11:42", which is a statement about the state of your own address book, not about the chain.

The second is that the log line happens before the return, unconditionally, on both branches. That sounds like a style point and is not. An internal transfer leaves no trace on any chain, so if your only durable record of a payout is the explorer, every internal withdrawal you ever processed is invisible to you. Logging at the decision point, rather than inside whichever sender was eventually chosen, is what makes the two populations countable afterwards. I have had to reconstruct that population retroactively on a custodial system once, from ledger entries alone, and it is not an afternoon's work...

Each rail's rules belong in the value, not in the caller#

The decision is a frozen dataclass that refuses to exist in a shape that makes no sense:

python
@dataclass(frozen=True, slots=True)
class Route:
    """The routing decision for one withdrawal, with the reason behind it."""

    rail: Rail
    destination: str
    reason: str
    account: str | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.destination, str) or not self.destination.strip():
            raise ValueError("destination must be a non-empty string")
        if not isinstance(self.reason, str) or not self.reason.strip():
            raise ValueError("reason must be a non-empty string")
        if self.rail is Rail.INTERNAL:
            if not isinstance(self.account, str) or not self.account.strip():
                raise ValueError("an internal route must name the account it credits")
        elif self.account is not None:
            raise ValueError("an external route has no internal account")

    @property
    def is_internal(self) -> bool:
        """True when the transfer stays on our own books."""
        return self.rail is Rail.INTERNAL

    @property
    def needs_confirmations(self) -> bool:
        """True when the transfer only settles once the chain confirms it."""
        return self.rail is Rail.EXTERNAL

An internal route must name the account it credits. An external route must not carry one. That second rule is the interesting half: it is there because the bug I expect is not a missing account, it is a stale one, a decision that says external while still holding an account value that came from an earlier lookup, or a retry, or a copy-paste in a test fixture. A credit booked against that account is money created out of nothing. Making the combination unconstructible costs four lines and removes the whole class.

Note also that is_internal and needs_confirmations are derived from rail rather than stored beside it. There is exactly one field that can be wrong. Anything a later stage wants to know about settlement it asks the decision, so the sender, the confirmation watcher and the reconciliation job cannot drift apart on what external means.

The lookup is where the money actually goes wrong#

Once routing is a value, the branch is boring and the risk moves entirely into the custody lookup. That lookup is asymmetric in a way worth saying out loud: a destination that is mine but fails to match sends real funds through a real rail and pays a real fee for something that should have been a free ledger entry, and you cannot undo it. The reverse error, matching an address that is not mine, credits an internal account for a payout that did not go anywhere. Neither is a crash. Both are quiet.

Which is why normalisation is the implementation's job, and it happens on both sides of the comparison:

python
class InMemoryCustody:
    """Custody backed by a mapping of deposit address to internal account."""

    __slots__ = ("_accounts",)

    def __init__(self, accounts: Mapping[str, str]) -> None:
        self._accounts: dict[str, str] = {}
        for destination, account in accounts.items():
            if not destination.strip() or not account.strip():
                raise ValueError("custody entries need an address and an account")
            self._accounts[_lookup_key(destination)] = account

    def account_for(self, destination: str) -> str | None:
        return self._accounts.get(_lookup_key(destination))


def _lookup_key(destination: str) -> str:
    return destination.strip().casefold()

A padded or mixed-case destination still finds its account, and an empty address or account is refused at construction rather than sitting in the map as a key that matches nothing. The test table is the specification here, and it is deliberately boring:

python
DESTINATIONS = [
    pytest.param(THEIRS, Rail.EXTERNAL, None, id="an-address-we-do-not-know"),
    pytest.param(THEIRS.upper(), Rail.EXTERNAL, None, id="case-does-not-make-it-ours"),
    pytest.param(OURS, Rail.INTERNAL, "acct-77", id="an-address-we-custody"),
    pytest.param(OURS.upper(), Rail.INTERNAL, "acct-77", id="upper-case"),
    pytest.param(f"  {OURS}  ", Rail.INTERNAL, "acct-77", id="surrounding-space"),
]

The case-does-not-make-it-ours row is the one I would keep if I could keep only one. Normalisation widens what matches. It must not widen whose it is.

One consequence of doing it this way: the decision records the destination exactly as it was asked for, spaces and all, while the match runs on the normalised key. The test asserts that directly. The log should show what the user submitted, because that is the string you will be comparing against a support ticket, not your internal key.

One place picks the sender#

With the decision in hand, selecting a sender stops being a branch at all:

python
def withdraw(request, key, custody, holds, policy, rails):
    ensure_approved(request, policy)
    decision = route(request, custody)
    holds.place(Hold.on(request))
    send = rails[decision.rail]
    return submissions.submit(
        key, request, lambda pending: broadcast(pending, send, holds)
    )

rails[decision.rail] is the entire dispatch. The ledger sender contains no chain code and the chain sender contains no ledger code, so neither of them can take the wrong path under a bad condition: the worst they can do is fail at what they are for.

Custody itself is a one-method runtime-checkable Protocol, account_for(destination) -> str | None. In production that is a database query against the deposit-address table. In the tests it is a dictionary, and one test supplies a class that knows exactly one address, to pin down that any lookup will do. Routing has no opinion about where the address book lives, which is the only reason the decision can be unit-tested without a chain, a ledger or a clock.

What I would do differently#

needs_confirmations is a boolean, and a boolean answers whether, not how deep. Confirmation depth is a per-asset number, so the boolean is probably best read as a floor ("this one waits"), and the depth still has to come from somewhere else. If I were starting again I would think harder about whether the route is the right carrier for that number, so that the settlement rule travels as one value rather than two.

reason is prose. That is correct for the reader I care about most, the person reading a log line at an awkward hour, and wrong for the other reader: you cannot aggregate over a sentence. The structured half of an internal decision is the account, and that is what I would group by if I had to report how much moved on my own books. I would not replace the sentence with a code. I would stop pretending the sentence is queryable.

And _lookup_key is a module-level helper, which quietly assumes one normalisation is right for every address space a withdrawal might target. The Custody protocol gets this right by leaving normalisation to the implementation. The in-memory implementation just happens to pick one, and that convenience is the kind of thing that later gets reused by a custody backend where it does not fit.

Close#

The sender's job is to send. The decision about which sender, and the reason behind it, is one function call older than the send, and it needs to outlive it by the length of your log retention. Written down as a value, a branch nobody remembers taking turns into a fact you can answer questions about.

react

$ new-project --brief

or email hey@polycratia.com