A deposit indexer is an exactly-once system built on infrastructure that guarantees no such thing. Miss a transfer and a user's money silently vanishes; count it twice and the ledger backs balances with money that doesn't exist — all while webhooks drop deliveries, providers rate-limit, and chains rewrite their own recent history.
This is the design that holds that line across the EVM chains — Ethereum, its L2s, and independent EVM networks like BNB Chain — plus Bitcoin and Litecoin, TRON, and Solana.
The decision everything else follows from
Most deposit detectors are designed as event processors: subscribe to a data provider's push feed, and credit the user when the notification arrives. It's the easiest version to build, and it's structurally fragile. A dropped message, a reconnect, a provider incident — and the event is simply gone. No push feed offers exactly-once delivery, so “we saw every deposit” becomes a promise you can't actually make. Worse, the failure is silent by construction: nothing tells you about the event you didn't receive.
We inverted it:
Historical reconciliation is the source of truth. Live events are only an acceleration path.
The core loop polls each chain from a durable cursor stored in the database and reconciles what it finds against the addresses we own. Push notifications exist too — but as an optional layer that only lowers latency. Turn them off and the system stays correct; it just gets a little slower.
The trick that makes both paths safe to run at once is identity. Every detected deposit carries a key derived from the transaction itself — chain, transaction hash, position within the transaction — never from which detector saw it. Recording a deposit is an idempotent upsert on that key, so the live path and the scan converge on one row. Deduplication is a property of the identity scheme, not of careful coding.
The safety window: closing a bug class instead of fixing bugs
Polling from a cursor has a classic failure mode: what if the process was down, a provider misbehaved, or a live event was dropped behind the cursor? Our answer is that every pass re-scans a time-based safety window behind the cursor before scanning forward.
Note the unit: a duration, not a block count. “Re-check the last thirty minutes” converts to a different number of blocks on every chain, computed from that chain's block time. Same guarantee everywhere, expressed once.
Why this matters: it converts “could we miss a deposit if X fails?” from a case-by-case investigation into a one-number question — is the safety window wider than the longest plausible outage? Make it so, and the entire missed-deposit class is closed by construction. And because recording is idempotent, rescanning the same ground over and over has no side effects; the upsert just finds the row already there.
This is the difference between a feature and a property. “We handle dropped events” is a feature you have to keep proving. “Detection is recoverable by construction within window W” is a property you assert once.
Four chains, one shape
Each chain family gets its own scanner, because the chains genuinely differ — in how you ask about history, in how finality works, in how they fail:
| Family | How it detects | Cursor granularity |
|---|---|---|
| EVM | filtered log queries over watched token contracts and recipients | one block cursor per network and asset — progress tracking doesn't grow with wallet count |
| Bitcoin / Litecoin | address history, paged newest-first back to the window edge | block height, per address |
| TRON | indexed account history per wallet and asset | timestamp, per wallet per asset |
| Solana | balance deltas per transaction — no instruction parsing | transaction signature, per address |
Whatever the source, every scanner emits the same normalized record — a deposit candidate — and everything downstream of that point is chain-agnostic. The rulebook states it bluntly: which provider found a deposit is descriptive metadata only, and must never drive ledger behavior. The moment crediting logic can ask “who found this?”, special cases start accumulating in the most dangerous code you own.
A few chain-specific choices worth stealing:
- Solana deposits come from balance deltas, not instruction parsing. We diff each transaction's before-and-after balances rather than decoding program instructions — so a transfer routed through some new protocol we've never heard of still shows up as “this address gained funds.”
- The EVM design decouples cost from growth. One filtered log query matches all watched token contracts against a batch of watched recipients, so detection cost scales with block ranges scanned, not with how many wallets you have. And if a query result is truncated by a page cap, the scanner refuses to advance its cursor — no silent gaps, ever.
- Bitcoin has a self-spend guard. Any transaction whose inputscome from an address we own is ignored — otherwise internal sweeps and change outputs get credited as fresh deposits. (Ask anyone who's run a UTXO indexer how they learned this.)
Vendors are adapters, and that's a load-bearing choice
A blockchain data provider is an implementation detail, and we treat it as one. Each scanner talks to its chain through a narrow port — “give me transfers to these addresses since this point” — and the concrete data source is an adapter behind it, chosen by configuration.
This boundary pays for itself three ways:
- Cost. Bitcoin can run against a hosted API today and a self-hosted node tomorrow with one configuration change, because both satisfy the same port. Dropping a vendor is a config edit, not a rewrite.
- Resilience.The TRON adapter rotates across multiple API keys, each with its own rate limiter and cooldown; when a key gets suspended, it's benched for exactly the suspension period and the next one takes over. If every key is suspended, the scanner degrades to a chain-wide block scan through a different source — heavy rate limiting costs latency, never completeness.
- Testability. Because the seam is an interface, the reliability scenarios — a missed deposit, a reorg, an internal transfer — run as fast, deterministic tests against fake providers and a real throwaway database. The money path is exercised end to end without touching a network.
Chains themselves are configuration too. Chain families (EVM, UTXO, TRON, Solana) are code; chain instancesand their assets are data. Adding a new EVM chain or token is a declarative change, not a new module. Even here there's a lesson we paid for: an adversarial review caught our network-name matching using substring checks — and "neutron-mainnet" contains "tron", which is exactly the kind of collision that files a deposit under the wrong chain. Matching is now exact-first, then explicit prefix, never a bare substring.
One money path, written once
Detection is plural and messy; crediting is singular and must be perfect. So all four chains funnel into a single ledger path, and every correctness guarantee is concentrated there — as close to the data as possible:
- Recording is the idempotent upsert described above.
- Crediting takes a per-deposit lock inside the database and performs the double-entry move as one atomic operation. Concurrent workers physically cannot double-credit, and a partial credit cannot exist — the guarantee lives where the data lives, not in application code racing across processes.
- Selection is provider-blind and starvation-proof.The maturity worker picks deposits to credit purely by ledger state — uncredited, sufficiently confirmed — paged with a keyset cursor so a backlog of permanently stuck rows can never block newer creditable ones. The naive “process the oldest N” query silently lacks that property.
- Reversal requires positive evidence. A Bitcoin zero-confirmation deposit that gets fee-bumped is replaced by a new transaction id. We reverse the stale row only when the original transaction is gone and one of the coins that funded it — remembered at detection time for exactly this purpose — has been confirmed-spent by a different transaction. Mere absence never reverses anything, because a vanished transaction can reappear, and a reversal is sticky.
Confirmations, by the way, are always computed from our ownview of the chain tip — never trusted from a notification payload. Live deliveries are treated as untrusted acceleration: authenticated, then re-validated against our wallet set and re-derived from authoritative data. A forged or garbled delivery can waste a lookup; it can't move money.
A deliberately boring runtime
The runtime is stateless workers over one shared database, and the scheduling layer is — like everything else — an interface with two implementations: a simple in-process timer for small deployments (with an in-flight guard so a slow pass never overlaps the next), and a durable job queue for horizontal scale. The scanners and the money path are identical under either; you swap the scheduler, not the domain.
Scaling out needs no coordinator. Each worker loads only the slice of wallets it owns, using a hash-based shard predicate pushed into the database query itself — so N workers partition the work with no leader, no lock, and no shared state. One process can run everything; a fleet can fan out per chain and per role. And because crediting is guarded by database locks, at-least-once job delivery is safe by default — the scheduler is allowed to be sloppy because the money path isn't.
Make the safety net audible
The system this one replaced didn't just miss a deposit once — it missed it silently. So the rebuild treats observability as part of the correctness story, and the metric we watch hardest is a strange one:
Recovered live-miss — the count of deposits that reconciliation found which the live path had dropped.
A non-zero value is the exact failure that motivated the whole design, surfaced as a routine dashboard number instead of a post-mortem. Around it: cursor lag per chain, the age of the oldest uncredited deposit, truncated-scan events, and provider error rates — with alerts throttled so a persistent condition is one message, not a thousand.
Paired with this is a discipline of fail-safe defaults: if a correctness-critical input can't be loaded — the chain tip, the set of addresses we own — the pass is skipped, not guessed. The system would rather be late than wrong, because late is recoverable and wrong is a loss.
Scar tissue: three lessons that shaped the design
The war stories are where the cursor semantics actually got settled.
The cursor that ate a deposit.TRON's cursor is a per-wallet timestamp. Early on, a failed fetch could still advance it — so a provider outage slid the cursor past deposits nobody had seen. The fix sounds obvious and wasn't, because it needs two rules pointing in opposite directions. On a successful pass, the cursor advances past what was seen but never sits further back than the recheck window — otherwise an idle wallet pins an ever-growing rescan forever. On a failed pass, that floor is deliberately not applied — otherwise a long outage would drag every cursor forward past deposits that were never fetched. Advance eagerly on success; freeze completely on failure.
The pagination that quietly stalled. The maturity worker pages through uncredited deposits by creation time. A timestamp that round-trips between the application and the database can shift subtly — timezone interpretation is enough — and a shifted boundary means the page cursor stops moving while everything looks healthy. The fix was to page on a bare number instead of a date. The design lesson is broader: keyset cursors must be built from values that survive the round trip bit-for-bit.
The alert that never stopped. An aging-deposit alarm kept firing after the backlog it complained about had drained, because nothing reset the gauge to zero; and unthrottled alerts turn one persistent condition into a wall of noise that trains humans to ignore the channel. Observability needs the same rigor as the money path — a misleading alarm is a correctness bug in the humans.
The trade-offs, honestly
- Latency.Reconciliation finds deposits on the next pass, not the millisecond they confirm. The live layer closes the gap when it matters, but the floor is seconds-to-minutes, not milliseconds. For deposits, we'll take that trade every time.
- Always-on compute.A continuously polling service doesn't scale to zero. For a 24/7 custodial money path that runs regardless, that's a non-cost — but it's a real property of the design.
- TRON needs an indexer.Its full nodes can't answer “every transfer to this address,” so detection there depends on someone's indexed API. Multiple keys, client-side rate limiting, a suspension circuit breaker, and the block-scan fallback soften the dependency, but it's the one we'd most like to bring in-house — and the port is already shaped for that swap.
None of these are oversights. Each is the cheap side of its trade, chosen on purpose.
What we'd tell you to steal
If you're building deposit detection — for any chain, on any stack — the portable ideas are:
- Reconcile from a durable cursor; demote push events to an accelerant. Correctness should be a property of your loop, not of someone else's delivery guarantees.
- Re-scan a time-based safety window every pass, sized as a duration and converted per chain. One number closes the missed-deposit class.
- Derive deposit identity from the transaction, never the detector, and make recording an upsert. Then run as many detection paths as you like.
- Put the strongest guarantees where the data lives. Locks and atomic operations in the database beat careful coordination in application code, every time.
- Reverse only on positive evidence.Absence of a transaction is not evidence it's gone.
- Alert on the failure you designed against.Our favorite metric counts the disasters that didn't happen.
None of these pieces is novel on its own. The system works because the boundaries are drawn so that the dangerous part — money movement — is small, stable, and provable, and the volatile parts — vendors, chains, scale — can never reach it.