Why Your Deposit Detection Misses Money

Maciej Lewandowski · · Field notes from custody & wallet infrastructure

Deposit detection sounds like the easy part of a custody platform. Watch an address, see an incoming transaction, credit the client. Every tutorial does it in twenty lines.

The twenty-line version loses money in production. Not often, and that's the trap: rare enough that you ship it and move on, and then one day reconciliation shows a number that doesn't match and nobody can say why. I've spent enough time on this class of bug to list the failure paths from memory, so here they are.

ERC-20: stop reading tx.to

Everyone's first scanner checks tx.to. For token deposits that field points at the token contract, not at your deposit address, so the only thing worth indexing is the Transfer event log filtered by recipient topic. Fine. Everyone learns that in week one.

Week two is worse. A user deposits from a Safe or from an exchange hot wallet, and the value moves inside an internal call. For ERC-20 you still get the event. For native ETH you get nothing: no transaction with your address in to, no log, nothing in any of the places a normal scanner looks. If you're not consuming trace-level data (debug_traceBlock, or a provider that surfaces internal transfers), you are quietly not seeing some fraction of your ETH deposits. Today, right now, in production.

Fee-on-transfer tokens are the opposite problem. The event says 100, the balance grows by 97, and if you credit the event value you have just minted 3 tokens on your own ledger. For any token you haven't explicitly whitelisted as standard, credit the balance delta, not the event value. It's uglier and slower. Do it anyway.

Two more, quickly. Most serious tokens are upgradeable proxies, so decode events against the implementation ABI resolved through the EIP-1967 slot, and alert when the implementation changes, because token semantics can change under your feet on upgrade day. And USDT: no return boolean, 6 decimals. Everyone knows about USDT. Every integration still gets bitten by USDT exactly once.

chain blocks scanner logs + traces PENDING not spendable N conf CONFIRMED credited reorg: walk it back + compensating entry

Confirmations

A deposit sitting in a block is not a fact. It's a probability that grows with depth, and every chain prices it differently: Bitcoin's 6 confirmations is a policy choice, Ethereum gives you explicit finalized checkpoints, Solana gives you commitment levels. Fast chains with probabilistic finality reorg shallowly all the time. That's weather, not a black swan.

What worked for us was embarrassingly simple. Pending and confirmed are two different balances, and nothing is spendable until the chain-specific threshold. One threshold per chain, reviewed, versioned, boring.

The part people skip is the backwards path. When a block containing a credited deposit drops out of the canonical chain, the deposit has to walk backwards through its state machine: confirmed to pending, maybe to gone. The ledger entry gets reversed with an explicit compensating entry, never an UPDATE, and a human gets paged. If your pipeline has no backwards transition, it isn't a state machine. It's optimism with extra steps.

UTXO chains

On Bitcoin the unit of deposit is the output, not the transaction. One transaction can pay the same address three times. Count transactions and you undercount money.

Dust is nastier than it looks. Sub-economic outputs are technically deposits, so the naive scanner credits them, and now you've promised a client money that costs more to move than it's worth. You want a dust policy before your first dusting attack, not after, because dusting doubles as a deanonymization probe against your wallet clusters.

The rest of the UTXO list: HD wallets (BIP32/44) mean fresh derived addresses per client and a gap limit your scanner must respect, or it silently stops watching addresses the wallet already handed out. And once ops sweeps deposits to hot or cold storage, the on-chain balances of deposit addresses stop matching client balances. Reconciliation has to model the sweep as its own event or everything looks broken at once.

Solana

SPL deposits don't land on the client's address. They land on an Associated Token Account, a separate address derived from the owner, the token program and the mint, and the deposit transaction may create that account on the fly. A scanner written with EVM instincts, watching the owner address for incoming value, sees literally nothing. Watch token accounts, resolve their owners, and pick confirmed vs finalized as deliberately as you pick confirmation depths anywhere else.

Reconciliation is the actual product

Every mechanism above still misses occasionally. A webhook drops a block. A token does something creative. Someone whitelists a new chain with the wrong threshold.

The custody systems that survive audits are not the ones with the cleverest scanner. They're the ones where an independent process compares the internal ledger against on-chain reality all day, per asset, per address, and refuses to stay quiet about drift. Batch reconciliation at end of day means finding out about a hole twelve hours late.

And underneath all of it: double-entry, append-only, balances derived as sums of entries. When a deposit path misbehaves - when, not if - an append-only ledger lets you prove exactly what happened and correct it with a compensating entry. A mutable balance column leaves you explaining a number to an auditor with no history behind it.

None of this is exotic. It's just the difference between a demo and something a regulated institution can put real balances on.