Why Your Deposit Detection Misses Money
Deposit detection sounds like the easy part of a custody platform. Watch an address, see an incoming transaction, credit the client. Every tutorial covers it in twenty lines.
Then you run it in production against real chains, real tokens and real users, and you discover that the naive version silently loses money. Not often, and that is exactly the problem: rare enough that you ship it, frequent enough that reconciliation will one day show a hole nobody can explain. These are the paths I have seen break, chain by chain.
ERC-20: the transaction's to field lies to you
The first mistake everyone makes is scanning transactions and checking tx.to.
For ERC-20 deposits that field points at the token contract, not at your deposit address.
The only reliable primitive is the Transfer event log, filtered by the recipient
topic. So far, so standard. The edge cases are where deposits go missing:
- Internal transfers. A user deposits from a smart-contract wallet
(Safe, an exchange's hot wallet, an aggregator route). The value movement happens inside
an internal call. If you only look at top-level transactions you will never see it.
Event logs catch ERC-20s either way, but for native ETH there is no log at all:
an internal ETH transfer to your address produces no transaction with your address in
toand no event. You need trace-level data (debug_traceBlock, or a provider that exposes internal transfers) or you will miss real money. - Fee-on-transfer tokens. The
Transferevent says 100, your balance grows by 97, because the token skims a fee insidetransfer(). If you credit the event amount, you just created 3 tokens out of thin air on your ledger. The fix is unglamorous: credit from the balance delta (balanceOfbefore vs after, or per-block accounting), not from the event value, for any token you have not explicitly whitelisted as standard. - Proxy contracts. Most serious tokens are upgradeable proxies. The address you whitelisted keeps its balance and its history, but the code behind it can change on upgrade. Decode events against the implementation ABI, resolved through the EIP-1967 slot, and treat an implementation change as an operational event worth alerting on, because token semantics can change under your feet.
- The boring ones. USDT returns no boolean and uses 6 decimals, not 18. Everyone knows. Every integration still gets bitten once.
Confirmations: crediting is a promise you might have to break
A deposit is not a fact when it appears in a block. It is a probability that increases with depth, and every chain prices that differently. Bitcoin finality is probabilistic (the classic 6 confirmations is a policy choice, not physics). Ethereum has explicit finalized checkpoints. Solana has commitment levels. And chains with fast blocks and probabilistic finality reorg shallowly as a matter of routine, not as a black-swan event.
Two rules kept us honest:
- Pending and confirmed are different balances. Show the user the pending deposit immediately, but nothing spendable until the chain-specific threshold. One threshold per chain, reviewed, versioned, and boring.
- A reorg handler is not optional. When a block containing a credited
deposit disappears from the canonical chain, the deposit must transition backwards in its
state machine (confirmed → pending → possibly gone), the ledger entry must be
reversed by an explicit compensating entry (never an
UPDATE), and a human must be alerted. If your deposit pipeline has no backwards transition, you do not have a state machine, you have a hope machine.
UTXO chains: deposits are outputs, not transactions
On Bitcoin the unit of a deposit is the UTXO, and one transaction can carry several outputs to the same address. Count transactions and you undercount money. Other production lessons from the UTXO side:
- Dust. Outputs below the economic cost of spending them are still deposits according to your naive scanner. Credit them and you have promised the client money that costs more to move than it is worth. You need a dust policy, and you need it before the first dusting attack, not after, because dusting is also a tracking technique aimed at deanonymizing your wallet clusters.
- Address reuse and gap limits. HD wallets (BIP32/44) mean every client gets fresh derived addresses, and your scanner must respect the gap limit or it will stop watching addresses the wallet already handed out.
- Sweeping changes the picture. The deposit lands on the client's derived address, then ops sweeps it to hot or cold storage. Ledger-wise these are two different events, and if reconciliation compares client balances against on-chain balances of deposit addresses, sweeping makes everything look wrong unless the model knows about it.
Solana: the deposit does not go where you think
SPL token deposits do not credit the client's main address. They credit an Associated
Token Account, a separate address derived from the owner, the token program and the mint.
The deposit transaction may
even create that account on the fly. A scanner built on EVM assumptions, watching
the owner address for incoming value, sees nothing at all. You watch token accounts, you
resolve their owners, and you pick a commitment level (confirmed vs
finalized) with the same care you pick confirmation thresholds elsewhere.
The only real fix: reconciliation as a continuous process
Every mechanism above will still occasionally miss. A provider webhook drops a block. A token does something creative. An engineer whitelists a chain with the wrong threshold. The systems that survive audits are not the ones with the cleverest scanner. They are the ones where an independent process continuously compares the internal ledger against on-chain reality and refuses to stay quiet about drift:
And the ledger itself: double-entry, append-only, balances derived as sums of entries.
Never UPDATE balance. When (not if) a deposit path misbehaves, an append-only
ledger means you can prove exactly what happened and correct it with a compensating entry.
A mutable balance column means you get to explain to an auditor why the number is what it
is, with no history to back you up.
A checklist, if you are building this
- ERC-20 by
Transferlogs, native coin by traces, never bytx.toalone. - Credit unknown tokens by balance delta, not event value.
- Resolve proxies via EIP-1967; alert on implementation changes.
- Per-chain confirmation thresholds; pending vs confirmed as separate balances.
- Deposit state machine with backwards transitions for reorgs.
- UTXO: track outputs, enforce a dust policy, respect gap limits, model sweeps.
- Solana: watch ATAs, not owner addresses.
- Continuous reconciliation with human alerting, on an append-only double-entry ledger.
None of this is exotic. All of it is the difference between a demo and a custody platform that a regulated institution can put real balances on.