How Multi-Chain Deposit Detection Actually Works
The engineering behind crediting a stablecoin deposit: block scanning, log filtering, cursors, confirmation depth, reorganisation handling and idempotent crediting.
In short
Detecting a deposit means scanning blocks for token transfers to your addresses, tracking how far you have scanned, waiting for confirmation depth, re-checking against reorganisation, and crediting exactly once. Each step has a way to get it wrong.
Accepting on-chain deposits looks simple: watch for money arriving, credit the account. Every part of that sentence hides a decision that, made wrongly, either loses deposits or credits money that was never received.
Finding the transfers
ERC-20 transfers are events, not transactions you can look up by recipient. A transfer emits a Transfer(address,address,uint256) log, and finding deposits means querying logs filtered by contract, by the to topic, over a block range.
// Conceptually: all Transfer events to any of our addresses,
// for the tokens we support, in a range of blocks.
eth_getLogs({
address: [USDT, USDC],
topics: [TRANSFER_SIGNATURE, null, ourAddressesAsTopics],
fromBlock, toBlock,
})
Filtering server-side is essential. Fetching every transfer of a major stablecoin and filtering locally would mean processing an enormous volume of irrelevant events; the node can do it far more cheaply.
The block range trap
Here is a failure worth describing because it is silent. Public RPC endpoints cap how many blocks eth_getLogs may span, the cap differs between providers, and none of them advertise it. Ask for too many and you get a flat "invalid parameters".
A scanner with a fixed range larger than the cap never scans a single block, forever, while logging an error that looks like a configuration problem. The cursor never advances, deposits are never seen, and nothing about the symptom points at the cause.
The robust answer is to narrow adaptively: halve the range and retry until the endpoint accepts it, then remember the width that worked. A narrower range is never wrong, only slower — the scanner advances less per cycle and catches up over several.
Cursors, and the first-run problem
A scanner tracks how far it has read in a cursor per chain. Two decisions follow.
Where to start on first run. Scanning a chain from genesis is not viable, so scanners start near the head. The consequence is that any deposit sent before the first scan is never seen — and because the cursor only moves forward, waiting will not recover it. A programme needs a way to rewind the cursor deliberately, and that recovery path should exist before it is needed.
How far behind the head to scan. Scanning right to the tip means repeatedly processing blocks that may be reorganised away. Staying a block or two behind reduces churn at negligible cost.
Confirmation depth
A transaction in the latest block is not settled. It is in a block that a competing block may replace. Depth is the defence, and the right depth depends on the chain:
| Network | Confirmations | Why |
|---|---|---|
| Ethereum | 12 | Long-standing convention for economic finality |
| BNB Smart Chain | 20 | Faster blocks, so more of them for equivalent wall-clock assurance |
| Base | 30 | Fast blocks; depth chosen for time rather than count |
| Kaanch Network | 12 | Matched to the chain's finality characteristics |
Comparing the numbers directly is misleading. What matters is elapsed time and the cost of reversing that many blocks, not the integer.
Surviving a reorganisation
Depth reduces the risk; it does not eliminate it. The complete pattern records a deposit on sight, then re-verifies before crediting:
- On detection, store the transfer with its transaction hash, log index, block number and block hash, in a
DETECTEDstate. - When it reaches the required depth, fetch the transaction again.
- If it is still present in the canonical chain, credit the balance.
- If it has vanished, or is now in a block with a different hash, mark it reorganised and credit nothing.
Storing the block hash is what makes step four possible. A block number alone cannot distinguish "the same block" from "a different block at the same height", which is precisely what a reorganisation produces.
Crediting exactly once
A scanner will see the same transfer more than once — a rewound cursor, an overlapping range, a restart mid-batch. Idempotency has to be structural rather than defensive.
The reliable approach is a unique constraint on (chain, transaction hash, log index) with an insert that does nothing on conflict. A second sighting inserts zero rows and credits nothing, without any code needing to check first. Checking-then-inserting is a race; a constraint is not.
The log index is essential. One transaction can contain several transfers to the same address, and they are distinct deposits.
Addresses without a hot wallet
Giving every account its own deposit address usually means a hierarchical deterministic wallet. The important choice is what the server holds.
- Watch-only. The server holds an extended public key and derives addresses to watch. It cannot spend. This is the right default.
- Hot. The server holds the seed and can sign. Necessary for automated sweeps, and it means anyone who reads the environment can move every user's funds.
What to build for operations
- A rewind command. Deposits sent before first scan, or during an outage, need a deliberate re-read.
- A visible cursor. A cursor stuck at zero should be obvious, not something discovered when a user complains.
- Alerting on a stalled scan. A cycle that fails repeatedly is a silent outage; nothing is broken from the outside, deposits simply stop arriving.
- Detected and credited notifications. Funding is observable rather than something users poll for.
Frequently asked questions
How are blockchain deposits detected?
By scanning new blocks for ERC-20 Transfer events filtered to the programme's deposit addresses, tracking progress in a per-chain cursor, and crediting once the transfer reaches a confirmation depth appropriate to that chain.
Why do deposit scanners break with "invalid parameters"?
Public RPC endpoints cap the block span of eth_getLogs, the cap varies by provider and is not advertised. A fixed range above the cap means the scanner never advances at all. Narrowing the range adaptively and remembering what worked resolves it.
How do you handle a chain reorganisation?
Record the deposit with its block hash on detection, then re-fetch the transaction once it reaches confirmation depth. If it is absent or now sits in a block with a different hash, mark it reorganised and credit nothing.
How do you avoid crediting the same deposit twice?
A unique constraint on chain, transaction hash and log index, with an insert that does nothing on conflict. Structural idempotency beats checking before inserting, which is a race.
Issue your first card on ON5
Fund an account with USDT or USDC and issue a branded Visa or Mastercard virtual card. The minimum is $5.
Open the dashboard