A deposit was credited, then vanished after a reorg
“The indexer saw a deposit and credited the user, but the block was reorganized and the transfer no longer exists on the canonical chain.”
THE PRINCIPLE
A block at the tip is a proposal, not a fact. Finality is a property you query, not a confirmation count you guess.
FIRST MOVES
- On Ethereum mainnet, read the finalized block tag (typically about two epochs, ~13 minutes) instead of hard-coding N confirmations; safe is a weaker, faster head.
- Show deposits at latest as pending; move funds to available only once their block is at or below finalized.
- Key credits by (chain_id, tx_hash, log_index) with a unique constraint so replays and re-scans cannot double credit.
- Store block number and hash per processed block; on parent-hash mismatch, roll back to the fork point and re-scan.
- Handle removed: true logs from filters and subscriptions; they mean a previously delivered log was reorged out.
- On L2s, learn the chain's own labels: OP Stack unsafe, safe, and finalized differ, and the 7-day bridge window is not transaction finality.
TOOLS: THEN → NOW
Fixed confirmation countssafe and finalized block tags (post-Merge), with confirmations still fine for low-value UX
Append-only log scannersReorg-aware indexers that track block hashes and roll back
PATTERN SNAPSHOT
const final = await client.getBlock({ blockTag: "finalized" });
const logs = await client.getContractEvents({
address: token, abi: erc20Abi, eventName: "Transfer",
args: { to: depositAddress },
fromBlock: cursor + 1n, toBlock: final.number,
});
for (const log of logs) {
await tx.query(
`INSERT INTO deposits (chain_id, tx_hash, log_index, amount)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
[chainId, log.transactionHash, log.logIndex, log.args.value],
);
}
await tx.query("UPDATE cursors SET block = $1", [final.number]);CLOSE THE AI. EXPLAIN THIS.
Why is (tx_hash, log_index) a better idempotency key than tx_hash alone for token deposits?HOW IT WORKS UNDERNEATH
RELATED SYMPTOMS
SOURCES
Guide reviewed