Confirmation depth for an unlisted asset: one, not never
A confirmation policy written as a mapping of per-asset depths is incomplete by construction: the mapping names the assets someone thought about, and a watched address is free to receive one nobody did. Something still has to decide how deep is deep enough for that asset. The answer that sounds safest, hold the payment until a human decides, is the one that loses payments quietly.
This came up in chain-watch, a small library I keep for the part of custodial deposit handling that is always the same: follow a set of addresses, count confirmations, report each deposit exactly once, hand it back if the chain takes the block away. Crediting is a one-way door. Once the balance moves, the customer can spend it, while a chain is under no obligation to agree with itself about what happened three blocks ago. The depth policy is where the caller states how much agreement it wants before that door opens.
Depth is a judgment about one asset#
There is no correct number of confirmations. A chain with fast blocks and a chain that settles in minutes do not deserve the same depth, and a token can deserve a stricter number than the coin it rides on: a single stablecoin transfer is routinely worth more than a single native transfer, while both are secured by exactly the same work. Per-asset depth is not a configuration nicety. It is the only honest shape I can give the knob.
So the policy is a default plus overrides, and the watcher asks it about every transfer it holds:
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 overrideThat is the complete case, and it is not the one that bites. The case that bites is the caller who writes only the overrides, because that is what people actually do: they know they care about two assets and they do not want to think about a third.
A mapping is a partial function#
The watcher never takes a raw depth. Its policy argument accepts a policy, a bare integer or a mapping, and everything goes through one classifier:
@classmethod
def coerce(cls, spec: DepthSpec) -> ConfirmationPolicy:
if isinstance(spec, ConfirmationPolicy):
return spec
if isinstance(spec, Mapping):
return cls(per_asset=spec)
if isinstance(spec, int) and not isinstance(spec, bool):
return cls(default=spec)
raise TypeError(
f"expected a ConfirmationPolicy, an int or a mapping of depths: {spec!r}"
)The order matters, and so does the one thing the mapping branch does not do: it does not raise the default. cls(per_asset=spec) leaves default at its dataclass value of 1. Pass policy={"ETH": 12} and you have asked for twelve confirmations on ETH and one confirmation on every asset in existence, including the ones that do not exist yet.
Written down like that it looks careless. It is the deliberate part.
"Hold it until someone decides" is a silent failure#
The alternative default is infinity: treat an unnamed asset as never deep enough and keep it in pending until an operator extends the policy. That one fails review in about a minute of thinking about operations.
A transfer waiting for a depth it can never reach produces nothing. poll() returns a PollResult that is falsy when both confirmed and reverted are empty, so if not watcher.poll() reads as "nothing happened," and that is exactly what it would say, poll after poll, while a real payment sits on a real address. No exception, no log line, no row in a queue anyone has a reason to look at. The pending map is an internal detail of the watcher, not an alerting surface, and nobody writes a dashboard for the thing they did not anticipate. The failure mode is a support ticket: the customer sent a token, the chain shows it arrived, the balance never moved, and the first person who can explain why is whoever reads the watcher's internals.
With the default at one confirmation, the same payment arrives as a deposit at the first block that contains it, carrying the depth it actually had. Depth counts the block itself, so a transfer sitting in the tip has one confirmation, and you see the whole truth in the object:
KNOWN_ASSETS = {"BTC", "ETH", "USDT"}
result = watcher.poll()
for deposit in result.confirmed:
if deposit.asset not in KNOWN_ASSETS:
# Reported, visible, and explicitly not credited.
review.record(deposit.key, deposit.address, deposit.amount, deposit.asset)
continue
ledger.credit(deposit.address, deposit.amount, deposit.asset)The unknown asset is now a record with an address, an amount, a confirmations count and a (tx_id, output_index) key, in a code path the caller wrote on purpose. Crediting it is still a decision a human makes. The difference is that the decision exists.
The trade this makes is worth stating plainly, because it is a real one. A shallow report is a weaker statement than a deep one, and if the caller blindly credits whatever confirmed contains, a mapping-only policy will credit an unknown token at depth one. That is a caller-side bug with a one-line fix. Infinite patience is a library-side behaviour with no fix at all, because there is nothing to fix: the system is working as designed and producing silence. Between a weak report the caller can gate on and no report the caller cannot see, I take the weak report every time.
Tightening is one call, and it does not mutate anything#
The loud default is only acceptable because correcting it is trivial. The policy is a frozen dataclass, its overrides are wrapped in a MappingProxyType after validation, and tightening returns a new value rather than editing one some other watcher may be holding:
def with_asset(self, asset: str, depth: int) -> ConfirmationPolicy:
"""Return a copy that requires ``depth`` for ``asset``."""
return ConfirmationPolicy(self.default, {**self.per_asset, asset: depth})So the operational answer to a shallow unknown deposit is a new policy and a watcher that uses it, not a mutation applied to a running object mid-poll. One knock-on effect is worth knowing: reorg_depth defaults to policy.max_depth, which is the largest of the default and the overrides. A mapping-only policy therefore sizes its reorg window from the assets that were listed, and the unlisted asset, reported at depth one, stays revertible for as long as that window lasts. The shallow report is not final. It can still come back as reverted.
Zero and True are not depths#
The same code that is permissive about an unnamed asset is strict about the number itself. Every depth, the default and each override, goes through one check:
def _validate_depth(depth: int, label: str) -> int:
if isinstance(depth, bool) or not isinstance(depth, int):
raise TypeError(f"confirmation depth for {label} must be an int: {depth!r}")
if depth < 1:
raise ValueError(f"confirmation depth for {label} must be at least 1: {depth}")
return depthA depth below one is refused because zero confirmations means crediting a payment that is not in any block. That is not a lenient policy, it is a different product: a claim about the mempool dressed up as a claim about the chain. There is no caller-side gate that recovers from it, because the deposit was reported as confirmed.
The bool rejection is the same reasoning one level down. True is an int in Python, and {"USDT": True} would quietly become one confirmation on the asset the caller most likely meant to protect. coerce refuses a bare True as a default for the identical reason. A config file that produced a boolean where a depth belongs is wrong, and the distance between wrong and a stricter-looking default of one is exactly the kind of gap that survives review.
Note the asymmetry: an asset nobody named is permitted and reported shallow, a depth nobody can justify is refused at construction. The unknown asset is the caller's ignorance about the chain, which is normal and recoverable. A zero depth is the caller's ignorance about what a confirmation is, which is not.
What I would do differently#
Asset names are matched exactly as the chain source spells them, per_asset.get(asset, self.default), no normalisation, so "usdt" and "USDT" are two different assets and only one of them has an override. Combine that with a default of one and a mis-keyed override degrades silently to a shallow credit that looks exactly like a legitimately unknown asset. The policy cannot catch this: it has no idea which assets the source can report.
The check belongs one layer out, at startup, where the deployment does know its universe: refuse to boot if an override names an asset the configured source never spells, and refuse to boot if an asset the deployment credits has no override. That turns the typo into a failed deploy instead of a weak deposit, and it leaves the policy a pure value with no knowledge of any particular chain. I would not fix it by lowercasing keys inside depth_for; the chain's spelling is the chain's, and the library guessing at case is probably a worse bug than the one it prevents.
The rule I keep from this: when a configuration is a mapping, the behaviour for everything the mapping does not name is not a leftover, it is the most important branch in the file. Pick the one that produces a visible, cheap-to-correct outcome, and write down why, because the safe-sounding alternative produces nothing at all, and nothing is very hard to debug.