Your app credited a deposit, then the block disappeared

A blockchain deposit reorganisation can leave your app showing money that is no longer there. You saw a transfer, credited the customer and later found that its block had been replaced. If the customer can already spend that credit, the problem reaches beyond your indexer.

We’ll use an EVM application for this example. A transfer appearing in a block is one stage. Reaching the finality required by your chosen chain is another. Let’s record those stages before deciding when the customer can use the funds.

Record what you observed

Store the chain ID, block number, block hash, transaction hash and log index for each token-transfer event. Use the token contract and integer base-unit amount, not a display symbol and floating-point value.

A block number alone is not enough. After a reorganisation, a different block can occupy that height. Store the block hash and parent hash too. They let you detect when the branch has changed.

Also verify that the event corresponds to the asset and recipient your business supports. Any arbitrary contract can emit a familiar-looking event; decoding a transfer isn’t enough to establish a valid deposit.

Keep provisional credit separate

An observed deposit can appear as pending without becoming available to withdraw or spend. Record the evidence and the policy state separately.

Once the deposit meets your finality rule, change its ledger state to make that credit usable. Give that change a unique ID. Replaying an event should not add the same money twice.

Ethereum’s proof-of-stake documentation describes its finality mechanism. Other EVM networks and rollups can expose different settlement and finality considerations. Don’t copy one chain’s timing into every integration. Ethereum finality reference.

Build a canonicality-aware projection

The example replaces a provisional block and checks that its deposit no longer contributes to the current view. It then finalises a replacement deposit. A contradiction involving the trusted finalised checkpoint causes a halt rather than an ordinary rollback.

import assert from 'node:assert/strict';
let canonical=new Map([[1,'a'],[2,'b']]);
let finalised=1;
const deposits=[{height:1,hash:'a',amount:10n},{height:2,hash:'b',amount:20n}];
function totals(){
  let available=0n,pending=0n;
  for(const d of deposits)if(canonical.get(d.height)===d.hash){
    if(d.height<=finalised)available+=d.amount;else pending+=d.amount;
  }
  return {available,pending};
}
function replace(height,hash){
  if(height<=finalised && canonical.get(height)!==hash)throw new Error('Finality contradiction: halt');
  canonical.set(height,hash);
}
assert.deepEqual(totals(),{available:10n,pending:20n});
replace(2,'c');assert.deepEqual(totals(),{available:10n,pending:0n});
deposits.push({height:2,hash:'c',amount:5n});finalised=2;
assert.deepEqual(totals(),{available:15n,pending:0n});
assert.throws(()=>replace(1,'other'),/halt/);
console.log('PASS: provisional reorg repair and explicit finality contradiction');

The example rebuilds a balance view from sample records. It does not run a blockchain node or a full accounting ledger. The checks show which deposits count as pending and which count as usable under this rule.

Find the common ancestor before replaying

When a new header doesn’t extend the stored canonical head, compare parent hashes until you find a common ancestor within your retained range. Mark observations from removed blocks as orphaned, then ingest the replacement branch.

Save branch changes and balance updates so a worker can recover after a crash. A restart halfway through must not leave both the old deposit and its replacement available to spend.

If the fork goes beyond the history you keep, pause and investigate. Do the same if it conflicts with a block you already trust as final. Treating every inconsistency as a routine shallow reorganisation can hide a provider or configuration failure.

Don’t erase the financial history

Keep the original observation and its orphaned status. If a provisional credit was exposed elsewhere, use explicit reversing or correcting entries according to your ledger model. Deleting the row makes the incident harder to explain.

The strongest simple policy is to avoid releasing provisional funds for consequential use. If the business deliberately accepts earlier deposits, document the exposure and keep controls appropriate to that risk. An indexer cannot make an early credit permanent.

Store the policy version used for release. If you later change the finality requirement, you need to know why an older deposit was released under the previous rule.

Check the indexer against your trusted chain view

Check stored canonical hashes against your configured trusted chain view. Record provider identity, observed head and finality references. One provider giving conflicting results should not silently rewrite the whole ledger.

Use checkpoints and an overlapping replay range to recover missed events. Deduplicate observations by their full identity while preserving evidence that a transaction moved between branches.

Token decimals affect display, not the integer transfer amount you store. Keep conversions explicit so a reorganisation repair doesn’t also introduce rounding or asset-identification errors.

Tell the customer which stage the deposit reached

Use separate pending, confirmed-under-policy and exception states. Avoid showing one green “complete” label immediately after event discovery.

If a provisional deposit disappears, explain that network confirmation changed and the balance is being checked. Don’t create a new transfer or make a promise about recovery before the chain evidence supports it.

Test branch replacement and restart

Use a local development chain or recorded fixtures to replace a provisional branch, replay duplicate logs and restart during projection updates. Check that available balance is never counted from both branches.

Then test an inconsistency at the trusted finality boundary and confirm the application stops the affected release path. The fixture here checks that decision without making any live transfer.

Start by storing block hashes and separating provisional from released credit. Without those two pieces, later rollback logic has too little evidence and too much already-committed business state to repair safely.

Run the example and adapt it

Save the code as 25-reorg-aware-deposits.mjs and run it with Node.js 24 or later:

node 25-reorg-aware-deposits.mjs

The code above includes assertions you can run locally. It tests the local decision logic with controlled inputs; it doesn’t connect to a live provider or establish production performance. Keep that boundary when adapting it to your application.

Technical references