polycratia

Where a deposit watcher starts reading is not the reorg window

· 8 min read

A deposit watcher carries two numbers that look interchangeable and are not. The confirmation depth decides when an incoming payment gets credited. The reorg window decides how long that credit can still be taken back. Neither one answers the third question, which block a poll should start reading from, and if you reuse the window floor as the read offset you get a watcher that is correct only while it runs at least once per block.

Nothing guarantees that. A poller runs on a timer, inside a process that gets deployed, restarted, killed for memory and occasionally throttled by its own node. A chain with ten-second blocks will mine several of them while a container image pulls. If the read starts at the window floor and the window is six blocks deep, a transfer that landed eight blocks ago is not in the answer at all, and you get no second chance to see it: a chain source returns the chain as it looks now, not a log of what was ever seen.

This post is about the three heights a poll has to consider, and why the lowest of them wins. What that buys you comes later. The code is in chain-watch, a small package I maintain for exactly this bookkeeping: https://github.com/polycratia/chain-watch

The window is a promise about reversal#

The reorg window is a policy statement, not an I/O hint. Its floor is tip.height - reorg_depth + 1, never below start_height. Everything at or above that line is provisional, and everything below it is history. That is the only reversibility the watcher actually has: a deposit inside the window can be handed back, and a deposit below it is final, so it will not be reverted and will not be reported twice.

The window therefore tells you which blocks you must re-examine to honour that promise. It says nothing about which blocks you have already examined. Those are two different facts, and the moment a poll can be late they stop coinciding.

Three floors, and the minimum wins#

So poll computes the window floor first, and then lowers it:

python
def poll(self) -> PollResult:
    tip = self._source.tip()
    floor = max(self._start_height, tip.height - self._reorg_depth + 1)
    addresses = sorted(self._addresses)
python
def _read_floor(self, floor: int) -> int:
    since = floor
    if self._last_tip is not None:
        since = min(since, self._last_tip + 1)
    for transfer in self._pending.values():
        since = min(since, transfer.block.height)
    return max(self._start_height, since)

That is the whole idea, in seven lines: three candidate heights, one minimum, clamped so a read never goes below the height the watcher was told to start at. The result is the since_height handed to the source's transfers method.

The window floor is there for the revert check. The second floor, self._last_tip + 1, is there for the gap: whatever the last poll saw, this poll reads from the block after it, however long ago that was. The third one is easy to miss.

The pending floor: let the chain confirm, not memory#

A transfer can wait longer than the window is deep. By default the window matches the policy's deepest requirement, but the window stays a separate argument precisely because you may want a tighter one:

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

Now take a USDT transfer that needs twelve confirmations. Depth counts the block itself:

python
def confirmations_for(block: BlockRef, tip: BlockRef) -> int:
    """Depth of ``block`` under ``tip``, counting the block itself as one."""
    if tip.height < block.height:
        return 0
    return tip.height - block.height + 1

By the time that transfer has eight confirmations it is still pending, and its block already sits two blocks below a six-deep window floor. If the read stopped at the window floor, the source would stop returning it. The watcher would then be promoting a deposit on the strength of a Transfer it is holding in a dictionary: crediting money because of what it remembers, not because of what the chain currently says. Worse, under the drop rule below, it would delete the transfer instead.

So the read reaches down to transfer.block.height for every transfer still pending. A payment waiting outside the window gets confirmed by the chain or taken away by it, and either way the decision comes from the current answer.

What the wide read licenses: dropping a missing pending transfer#

The three floors together guarantee one specific thing: every transfer the watcher still has an opinion about sits inside the range it just read. That is what makes absence meaningful, and absence is otherwise the hardest signal to interpret in this kind of service, I think.

Because of it, poll does its deletions first. Before any confirmation work, it removes every pending key the answer no longer holds, and reports nothing for them. Nothing is reported because nothing was credited: a pending transfer never moved a balance, so there is no reversal to announce. reverted is kept for deposits that were actually reported and are still inside the window. Only after that pass does the poll check window deposits against what was seen, hold newly seen transfers as pending unless they are already reported or settled, promote pending ones whose confirmations_for satisfies the policy, and settle deposits that have fallen below the floor. The last thing it does is record tip.height in _last_tip.

Ordering matters here. Deleting first means a transfer that vanished and a transfer that was re-mined at a different height go through the same mechanism: the key is dropped if the answer does not contain it, and re-added from the answer if it does, carrying whatever block it now sits in. Identity is the DepositKey, the (tx_id, output_index) pair, which names what was paid rather than where it was mined, so a replaced block leaves the key alone.

The snapshot has to carry the tip#

All of this collapses on restart unless the last tip survives the process. So it is part of the serialised state, next to the pending transfers, the reported deposits and the settled keys:

python
@dataclass(frozen=True, slots=True)
class WatcherState:
    start_height: int = 0
    pending: tuple[Transfer, ...] = ()
    reported: tuple[Deposit, ...] = ()
    settled: tuple[DepositKey, ...] = ()
    last_tip: int | None = None

to_dict writes it under STATE_VERSION, currently 2, and from_dict accepts None or an integer, rejecting a bool on the way through the integer coercion and raising InvalidState for any other version. The constructor refuses a non-zero start_height passed beside a state (two sources of truth for the same number is not something to resolve at runtime) and adopts the snapshot's start_height and last_tip, so a resumed watcher reads from the block after the one it stopped at.

The test that pins this down is the clearest statement of the whole argument:

python
def test_a_snapshot_carries_the_last_polled_tip():
    chain = ScriptedChain()
    watcher = DepositWatcher(chain, ["addr-1"], policy=2, reorg_depth=2)
    chain.mine(count=3)
    assert not watcher.poll()

    snapshot = json.loads(json.dumps(watcher.state.to_dict()))
    assert snapshot["last_tip"] == 3

    chain.mine(pay("tx-a"))
    chain.mine(count=4)
    resumed = DepositWatcher(
        chain,
        ["addr-1"],
        policy=2,
        reorg_depth=2,
        state=WatcherState.from_dict(snapshot),
    )
    result = resumed.poll()

    assert chain.last_query == (("addr-1",), 4)
    assert keys(result.confirmed) == [("tx-a", 0)]

The snapshot was taken at tip 3. The chain moved on to 8 while the process was down. A two-deep window would have the resumed watcher reading from height 7, and the payment in block 4 would never get credited. It reads from 4, and the assertion on last_query is there so that a future refactor cannot quietly narrow the range.

The cost, stated honestly#

The price is a wider read. A process that slept for a day asks its source for a range proportional to how long it slept, and one stuck pending transfer keeps the floor low until it resolves. That cost lands on the ChainSource implementation, which is where you can pay it properly: paginated, cached, or served from an indexer that was going to scan that range anyway. The alternative is cheaper per poll and wrong in exactly the situation you cannot design away, a late poll.

What I would do differently#

My first version of this used the window floor as the read offset, because when you are writing the reorg logic the window is the number on your mind. It was correct in a single long-running process that never fell behind, which is the one operating condition nobody can promise you... The bug it produces is also the worst kind in a payments path: silent and intermittent, visible only as a customer telling you a deposit never arrived.

Two things I would do from the first version rather than later. First, treat the last polled tip as part of the public contract instead of internal bookkeeping: a state object without it cannot resume honestly, only approximately. Second, log the computed since_height on every poll next to the tip height. When a deposit goes missing, that one number tells you right away whether the watcher never looked or looked and saw nothing, and those two failures have nothing in common.

The window governs how long the watcher may change its mind. Where it starts reading is a different question, and the answer is the lowest block it still has an opinion about.

react

$ new-project --brief

or email hey@polycratia.com