polycratia

A deposit is confirmed by the tip, not by first sight

· 9 min read

Crediting on-chain deposits fails in a specific way. The watcher sees a transfer, writes a row, and starts incrementing a confirmations column on a timer. Two things are already wrong: the count is stored instead of derived, and the first sighting has been treated as a fact rather than as an event. A confirmation count is not a property of a transfer. It is the distance between the block that currently contains it and the current tip, and that distance can go down as well as up.

I have been running custodial wallets and on-chain payment flows since 2018, through three market cycles, and the reorg itself is not the hard part. The hard part is that a reorg arrives after you have already told the rest of the system something. A ledger entry, a released order, a notification. Re-deriving chain state is cheap. Un-saying things is not. So the design question is not how to count confirmations. It is which single fact you own that the chain cannot rewrite underneath you.

Two facts, and only one of them is yours#

Everything a deposit watcher knows splits cleanly in two.

The chain owns where a transfer is: which block, at which height, under which hash, and how far that block sits below the tip. All of it is re-readable on every poll and all of it can change. There is no reason to persist any of it as authority, and every reason not to — a stored confirmation count is a cached answer to a question whose inputs move.

You own what you have already said out loud. That is the only thing the chain cannot re-derive for you, because it never happened on the chain. It happened in your process, to a consumer that has already acted on it.

That split is the whole architecture. Recount from the tip every time, and keep exactly one piece of state: the set of payments you have already reported. I wrote chain-watch around that split, so the poll loop reads as two lists rather than as a state machine:

python
from chain_watch import DepositWatcher

watcher = DepositWatcher(source, ["addr-1", "addr-2"], policy=3, reorg_depth=20)

result = watcher.poll()
for deposit in result.confirmed:
    credit(deposit)
for deposit in result.reverted:
    withdraw(deposit)

A transfer that is still too shallow does not appear in either list. It sits in watcher.pending and is recounted next time. Nothing downstream hears about a deposit that has not yet reached the depth you asked for, which means a shallow transfer that vanishes in a reorg produces no work at all — the ordinary case costs nothing.

The demand this puts on the data source is worth stating plainly, because it is the one assumption the whole thing rests on: transfers() must describe the current best chain. A transfer the source stops returning is a transfer the chain no longer has. If your node adapter serves a cached union of everything it has ever seen, this design cannot work, and neither can any other.

Depth is a policy, and it is per asset#

How deep is deep enough is not a library's decision. It is a risk decision, it differs per asset, and on rails where I have moved stablecoins in production it differs by an order of magnitude from what a low-value native transfer needs.

python
from chain_watch import ConfirmationPolicy, DepositWatcher

policy = ConfirmationPolicy(default=6, per_asset={"ETH": 12, "USDT": 12})
watcher = DepositWatcher(source, ["addr-1"], policy=policy)

policy.depth_for("BTC")   # 6, the default
policy.depth_for("USDT")  # 12, the override

Two details in there are deliberate and both are about failure modes.

A mapping of overrides leaves the default at one confirmation, not at some large safe-looking number. The reasoning is that an asset nobody listed should be reported rather than held forever. A silently stuck deposit is the worst outcome in this system: no error, no alert, just a user whose money never arrives and a support ticket that takes a day to trace. Loud and shallow beats silent and deep.

Asset names are matched exactly as the chain source spells them. That looks unhelpful until you have shipped a source adapter that reports usdt while the policy says USDT, and case-insensitive matching quietly papers it over on one deployment and not another. Exact matching turns a config mistake into an observable one: the asset falls to the default depth, which you can see in the policy rather than guess at from behaviour.

The dedup key names the payment, not the block#

The set of things you have already said needs a key, and choosing it badly is how exactly-once quietly becomes approximately-once.

Anything that includes the block — a block hash, a height, a composite of both with the transaction — is a key that changes when the chain reorganises. The same payment reappears under a new identity and gets credited twice. Keying on the address plus the amount is worse: two identical payments to the same address are indistinguishable, so a legitimate second deposit gets swallowed.

The key that holds is the one that names the payment itself. In chain-watch that is DepositKey, the (tx_id, output_index) pair naming the transaction output the money landed on. It says what was paid, not where it was mined, so it survives polls, duplicated blocks, restarts and reorgs. Every deposit carries it as deposit.key, and it is also the key downstream should deduplicate on, because your consumer needs the same protection your watcher has.

On top of that key the guarantees become statable, which matters more than it sounds — a guarantee you cannot write in one sentence is one you cannot test:

  • a transfer the source returns on every poll is confirmed once;
  • a source that replays a range it already served changes nothing;
  • a key that was reverted may confirm again, but only after the revert was reported, so credits and withdrawals always alternate;
  • a key below the reorg window is settled: never reverted, never reported again, whatever the source says afterwards.

The third one is the one that saves your ledger. A deposit that reorgs out and is mined again is held from scratch and confirms a second time, but the revert is always reported first. Downstream never has to reason about a credit that arrives twice with a withdrawal owed somewhere in between. The pairing is enforced, so the accounting stays a simple alternation.

Where you write the snapshot decides what the pipeline delivers#

Memory is not a guarantee. A process that restarts with an empty dedup set will re-report every deposit still inside its window, so the bookkeeping has to be a value you can persist and hand back:

python
import json
from chain_watch import DepositWatcher, WatcherState

state = WatcherState.from_dict(json.loads(snapshot.read_text()))
watcher = DepositWatcher(source, ["addr-1"], policy=3, state=state)

result = watcher.poll()
notify(result)
snapshot.write_text(json.dumps(watcher.state.to_dict()))

That three-line ordering is the actual delivery semantics of your pipeline, and it is worth being explicit that no library can decide it for you. Storing the snapshot in the same transaction as the notification gives exactly once. Storing it after gives at least once, because a crash in between replays the poll. Storing it before gives at most once, because the same crash drops the notification. Same watcher, three different products.

This is why the state is a plain value with to_dict() and from_dict() rather than a database integration. The interesting boundary is your transaction, not mine, and the only way to land inside it is to be a value you can write next to your own rows. The serialised form keeps amounts as strings so no decimal is rounded on the way out and back — money that survives a chain reorg and then loses a fractional unit to a JSON float is a comedy I would rather not stage.

What I would do differently#

One cost here is real and I have not solved it. Settled keys are kept for the lifetime of the state. That is what makes a replay from height zero safe, and it is also what makes the snapshot grow with the number of deposits ever seen. It is the honest trade: unbounded safety bought with unbounded memory.

In earlier wallet services I built, I would have reached for a stored confirmation count and a periodic repair job, and the repair job is exactly the thing that turns a reorg into a data-integrity incident instead of a routine poll. I would not do that again. But I would bound the settled set — once you accept a finality horizon, keys below it can be dropped, since a source that replays them is reporting blocks you have already declared unreachable. The reason it is not in there yet is that the horizon is a per-chain claim, and I would rather ship the version that is safe against a source I do not control than the version that is cheap against one I have assumed things about.

The underlying discipline generalises past crypto. Anywhere a system observes an external source and tells someone about it, the same split applies: derive everything you can re-read, persist only what you have already said, and key that record on the thing being described rather than on the circumstances of the observation. First inclusion is an event. Exactly-once notification is the product.

react

$ new-project --brief

or email hey@polycratia.com