Skip to content

How to test blockchain reorg handling for crypto deposit crediting

To test blockchain reorg handling, credit synthetic deposits on a local chain, remove their blocks with Bitcoin Core invalidateblock or the Anvil anvil_reorg method, and compare the ledger with the surviving chain. The check that catches double credits and orphaned deposits sums ledger entries per transaction ID and output index and compares each sum with the on-chain outcome.

Where a reorganised deposit costs the exchange money

A deposit credited at one confirmation can leave the chain minutes later. A blockchain reorg happens when a node switches to a competing branch: blocks it had accepted drop out, and their transactions either return to the mempool, confirm again in a different block, or vanish because a conflicting transaction confirmed instead. The Bitcoin developer guide states that nodes follow the most difficult chain to recreate and throw away stale blocks from shorter forks (checked on 2026-09-22).

Each outcome has a ledger defect attached to it:

  • Double credit. The same deposit confirms in a new block and the service credits it again because its key includes the block hash, or a replaced deposit and its replacement are both credited. The second withdrawal is paid from assets held for other clients.
  • Orphaned deposit. The service reverses a deposit that the reorg removed and then confirmed again, and never credits it back. The client is short the full deposit.
  • Missing reversal. A deposit replaced by a conflicting transaction stays credited. If the client already withdrew it, the late reversal leaves a negative balance.

On a crypto exchange or custodial wallet, each defect puts the ledger out of line with the register of positions a custodian keeps per client under MiCA Article 75(2); the MiCA testing requirements page maps that article to evidence. Public testnets rarely reorganise on the day a suite runs, so the procedure below forces each outcome on demand.

Blockchain confirmations and thresholds by chain

A confirmation threshold copied from one chain to another credits deposits before they are safe, because the meaning of depth differs by consensus.

Bitcoin. Finality is probabilistic. The payment processing section of the Bitcoin developer guide says the most recent block "gets replaced fairly often by accident", that a transaction paying a sufficient fee needs 10 minutes on average for one confirmation, and that software handling high-value transactions "should wait for at least six confirmations before treating a payment as accepted". The guide calls six "somewhat arbitrary", so the threshold is a risk decision per asset and amount that the business owns.

Ethereum. Ethereum's proof-of-stake documentation (updated 31 August 2026) defines 12-second slots and 32-slot epochs; a checkpoint with votes from at least two thirds of staked ETH becomes justified, the earlier justified checkpoint becomes finalized, and reverting a finalized block commits an attacker to losing at least one third of staked ETH. The JSON-RPC API (updated 28 June 2026) exposes the safe and finalized block tags. By our reading of the checkpoint rule, a block reaches finalized two to three epochs after inclusion under normal participation, roughly 13 to 19 minutes. When the chain goes more than four epochs without finalizing, the inactivity leak starts, and a service that credits only at finalized queues every deposit until finality resumes.

A threshold test therefore checks two things per chain and asset: the configured value matches the approved policy, and the credit fires on exactly that value and never one confirmation earlier.

The deposit state machine under test

Write the states down before any test runs. Every transition has a node signal and a ledger effect, and a test asserts both.

FromSignal from the nodeToLedger effect
detectedTransaction seen with 0 confirmationspendingNone
pendingConfirmations reach the chain thresholdcreditedOne credit keyed by txid and output index
pendingConfirmations drop back to 0detectedNone
creditedSame txid confirms in a different blockcreditedNone; block hash and height updated
creditedConfirmations drop to 0, transaction in mempoolon holdWithdrawals of the amount blocked
on holdTransaction confirms againcreditedNone
credited or on holdConfirmations negative (conflicted)reversedOne reversal linked to the original credit
anyConflicting replacement reaches the thresholdcredited (new key)One credit under the replacement txid

Two rules follow from the table. A re-mined transaction never creates a ledger entry. A credited transaction that is replaced creates exactly one reversal, and its replacement gets one credit under a different key once it reaches the threshold.

Procedure

1. Key every credit by transaction ID and output index

A Bitcoin input identifies the output it spends by txid and output index (developer guide, transactions). That outpoint survives a move to another block, and a replacement gets a new one. Enforce it as a unique constraint on credits, as the payment idempotency guide does for idempotency keys.

On Ethereum, the JSON-RPC API defines a log's logIndex as the "integer of the log index position in the block". A token transfer re-mined at a different position in a new block gets a new logIndex, so a key of transaction hash plus logIndex credits it twice. Key token deposits by transaction hash plus the log's position within its own transaction receipt.

2. Reorganise a Bitcoin deposit on regtest

invalidateblock is a hidden RPC that, per the Bitcoin Core 28.0 source, "permanently marks a block as invalid, as if it violated a consensus rule". Bitcoin Core tries to return the disconnected transactions to the mempool, but only for the first 10 blocks it disconnects from the tip (validation.cpp), so invalidating a block with 11 or more confirmations loses the deposit and the replacement step fails. The script creates both outcomes: one deposit confirms again, the other is replaced through bumpfee.

Shell
# Bitcoin Core 28.0 on regtest. Wallet "payer" plays the client; wallet "exchange" holds deposit addresses.
bitcoind -regtest -daemonwait
cli="bitcoin-cli -regtest"
$cli createwallet payer > /dev/null
$cli createwallet exchange > /dev/null
$cli generatetoaddress 101 "$($cli -rpcwallet=payer getnewaddress)" > /dev/null

# Two synthetic deposits in one block: 0.5 BTC for client C-1001, 0.25 BTC for client C-1002
DEP_A=$($cli -rpcwallet=exchange getnewaddress "client-C-1001" bech32)
DEP_B=$($cli -rpcwallet=exchange getnewaddress "client-C-1002" bech32)
TX_A=$($cli -rpcwallet=payer -named sendtoaddress address="$DEP_A" amount=0.5 replaceable=true fee_rate=10)
TX_B=$($cli -rpcwallet=payer -named sendtoaddress address="$DEP_B" amount=0.25 replaceable=true fee_rate=10)
$cli generatetoaddress 6 "$($cli -rpcwallet=payer getnewaddress)" > /dev/null
LAST_PROCESSED=$($cli getbestblockhash)   # the deposit service credits both deposits at 6 confirmations

# Remove the block holding both deposits and its five descendants
H=$($cli -rpcwallet=exchange gettransaction "$TX_A" | grep '"blockhash"' | cut -d'"' -f4)
$cli invalidateblock "$H"

# While both are back in the mempool, the payer replaces TX_B with a higher fee (RBF)
TX_C=$($cli -rpcwallet=payer -named bumpfee txid="$TX_B" fee_rate=25 | grep '"txid"' | cut -d'"' -f4)

# The competing chain grows one block longer than the removed branch
$cli generatetoaddress 7 "$($cli -rpcwallet=payer getnewaddress)" > /dev/null
$cli -rpcwallet=exchange listsinceblock "$LAST_PROCESSED" > listsinceblock.json

Output indexes change per run, so keys come from node output. Point the deposit service at this node with its threshold at 6 and export its ledger after the last step.

3. Compute the expected ledger actions from node output

listsinceblock returns wallet transactions from the fork point onward when the given block has left the main chain, plus a removed array. The file below is the regtest output of step 2, trimmed to the fields the check reads:

JSON
{
  "transactions": [
    {
      "txid": "123972df1d41d5fcfd92d0630a0505287d784a219f841883f48d47c842a6bb19",
      "vout": 0,
      "category": "receive",
      "amount": 0.25000000,
      "label": "client-C-1002",
      "confirmations": 7,
      "walletconflicts": ["83e606e8e8729f3c0ec65421d4e78f23ec38001ab060d552f86b202b24bd66b3"]
    },
    {
      "txid": "a5d13ac4b0774a90824a0240c7018077961547a14411207ecf402f4ddee1e60e",
      "vout": 1,
      "category": "receive",
      "amount": 0.50000000,
      "label": "client-C-1001",
      "confirmations": 7,
      "walletconflicts": []
    }
  ],
  "removed": [
    {
      "txid": "a5d13ac4b0774a90824a0240c7018077961547a14411207ecf402f4ddee1e60e",
      "vout": 1,
      "category": "receive",
      "amount": 0.50000000,
      "label": "client-C-1001",
      "confirmations": 7,
      "walletconflicts": []
    },
    {
      "txid": "83e606e8e8729f3c0ec65421d4e78f23ec38001ab060d552f86b202b24bd66b3",
      "vout": 0,
      "category": "receive",
      "amount": 0.25000000,
      "label": "client-C-1002",
      "confirmations": -7,
      "walletconflicts": ["123972df1d41d5fcfd92d0630a0505287d784a219f841883f48d47c842a6bb19"]
    }
  ]
}

The reference model below turns that file into the ledger actions the state machine requires. Amounts are parsed as decimals and converted to satoshis, so no float rounding reaches the comparison.

Python
import json
import sys
from decimal import Decimal, InvalidOperation

THRESHOLD = 6  # confirmations configured for BTC deposits in the build under test
SAT_PER_BTC = Decimal("100000000")
# Outpoints the ledger credited before the reorganisation, with their ledger entry IDs
CREDITED = {
    ("a5d13ac4b0774a90824a0240c7018077961547a14411207ecf402f4ddee1e60e", 1): "L-0001",
    ("83e606e8e8729f3c0ec65421d4e78f23ec38001ab060d552f86b202b24bd66b3", 0): "L-0002",
}


def to_sat(amount):
    return int((Decimal(amount) * SAT_PER_BTC).to_integral_exact())


def expected_actions(since):
    receives = [t for t in since["transactions"] if t["category"] == "receive"]
    live = {(t["txid"], t["vout"]) for t in receives if t["confirmations"] > 0}
    actions = []
    for r in since.get("removed", []):
        key = (r["txid"], r["vout"])
        if r["category"] != "receive" or key not in CREDITED:
            continue
        if key in live or r["confirmations"] > 0:
            continue  # re-added to the active chain: the credit stands
        if r["confirmations"] < 0:
            actions.append(("REVERSE", r["label"], key, -to_sat(r["amount"]), CREDITED[key]))
        else:
            actions.append(("HOLD", r["label"], key, 0, CREDITED[key]))
    for t in receives:
        key = (t["txid"], t["vout"])
        if key in CREDITED or t["confirmations"] < 1:
            continue
        state = "CREDIT" if t["confirmations"] >= THRESHOLD else "PENDING"
        actions.append((state, t["label"], key, to_sat(t["amount"]), ""))
    return actions


def main(path):
    try:
        with open(path, encoding="utf-8") as f:
            since = json.load(f, parse_float=Decimal)
        actions = expected_actions(since)
    except (OSError, json.JSONDecodeError) as exc:
        print(f"ERROR cannot read {path}: {exc}")
        return 2
    except (KeyError, TypeError, InvalidOperation) as exc:
        print(f"ERROR unexpected listsinceblock shape: {exc!r}")
        return 2
    for action, client, (txid, vout), sat, reverses in actions:
        print(f"{action:8} {client:14} {txid[:12]}:{vout} {sat:>11} {reverses}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "listsinceblock.json"))

Run against the sample file, the model prints one reversal and one credit, and nothing for the re-mined deposit of client C-1001:

Output
REVERSE  client-C-1002  83e606e8e872:0   -25000000 L-0002
CREDIT   client-C-1002  123972df1d41:0    25000000

4. Reorganise an Ethereum deposit on Anvil

Foundry's Anvil RPC methods include anvil_reorg, which rewinds by a depth and mines replacement blocks back to the same height with optional transactions, and anvil_rollback, which shortens the chain without replacements (checked on 2026-09-22). The two default accounts below are Anvil's public development accounts; no real funds or keys are involved.

Shell
# Foundry anvil 1.8.3, default settings: chain ID 31337, 32 slots per epoch
anvil &
export ETH_RPC_URL=http://127.0.0.1:8545
PAYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266     # anvil default account 0
DEPOSIT=0x70997970C51812dc3A010C7d01b50e0d17dc79C8   # anvil default account 1, synthetic deposit address
cast rpc anvil_mine 100
TX=$(cast send --unlocked --from $PAYER $DEPOSIT --value 1ether --async)
cast receipt $TX blockNumber                      # 101
cast block safe --field number                    # 69 = 101 - 32
cast block finalized --field number               # 37 = 101 - 64
RAW=$(cast rpc eth_getRawTransactionByHash $TX | tr -d '"')

# Re-mined: replace block 101 with a new block holding the same signed transaction
cast rpc anvil_reorg 1 "[[\"$RAW\", 0]]"
cast receipt $TX blockHash                        # new block hash at the same height, 101

# Dropped: replace block 101 with an empty block
cast rpc anvil_reorg 1 '[]'
cast rpc eth_getTransactionReceipt $TX            # null: the deposit no longer exists

Expected: the re-mined deposit keeps one credit; the dropped deposit is never credited at a finalized threshold, or gets one reversal below it.

5. Compare the ledger with the chain outcome

The harness records the surviving chain's view of each outpoint. The ledger export below comes from a synthetic build with one defect: its reorg handler compares confirmations >= 0 where the state machine requires < 0.

Data
chain,txid,vout,client_id,amount_sat,outcome,conflicts_with
BTC,a5d13ac4b0774a90824a0240c7018077961547a14411207ecf402f4ddee1e60e,1,C-1001,50000000,canonical,
BTC,83e606e8e8729f3c0ec65421d4e78f23ec38001ab060d552f86b202b24bd66b3,0,C-1002,25000000,conflicted,123972df1d41d5fcfd92d0630a0505287d784a219f841883f48d47c842a6bb19
BTC,123972df1d41d5fcfd92d0630a0505287d784a219f841883f48d47c842a6bb19,0,C-1002,25000000,canonical,83e606e8e8729f3c0ec65421d4e78f23ec38001ab060d552f86b202b24bd66b3
Data
entry_id,client_id,chain,txid,vout,entry_type,amount_sat,reverses_entry_id,created_at_utc
L-0001,C-1001,BTC,a5d13ac4b0774a90824a0240c7018077961547a14411207ecf402f4ddee1e60e,1,DEPOSIT_CREDIT,50000000,,2027-03-04T10:15:02Z
L-0002,C-1002,BTC,83e606e8e8729f3c0ec65421d4e78f23ec38001ab060d552f86b202b24bd66b3,0,DEPOSIT_CREDIT,25000000,,2027-03-04T10:15:02Z
L-0003,C-1001,BTC,a5d13ac4b0774a90824a0240c7018077961547a14411207ecf402f4ddee1e60e,1,DEPOSIT_REVERSAL,-50000000,L-0001,2027-03-04T10:31:47Z
L-0004,C-1002,BTC,123972df1d41d5fcfd92d0630a0505287d784a219f841883f48d47c842a6bb19,0,DEPOSIT_CREDIT,25000000,,2027-03-04T10:31:47Z

Load the first file as chain_outcome and the second as ledger_entries, then run:

SQL
-- Check 1: net ledger amount per outpoint equals the chain outcome
WITH net AS (
  SELECT chain, txid, vout, SUM(amount_sat) AS net_sat
  FROM ledger_entries
  GROUP BY chain, txid, vout
),
expected AS (
  SELECT o.*, CASE WHEN o.outcome = 'canonical' THEN o.amount_sat ELSE 0 END AS expected_sat
  FROM chain_outcome o
)
SELECT e.client_id, e.txid, e.vout, e.outcome, e.expected_sat,
       COALESCE(n.net_sat, 0) AS net_sat,
       CASE WHEN COALESCE(n.net_sat, 0) > e.expected_sat THEN 'OVER_CREDITED'
            ELSE 'UNDER_CREDITED' END AS break_class
FROM expected e
LEFT JOIN net n ON n.chain = e.chain AND n.txid = e.txid AND n.vout = e.vout
WHERE COALESCE(n.net_sat, 0) <> e.expected_sat;

-- Check 2: ledger entries for outpoints the chain never had
SELECT l.entry_id, l.txid, l.vout
FROM ledger_entries l
LEFT JOIN chain_outcome o ON o.chain = l.chain AND o.txid = l.txid AND o.vout = l.vout
WHERE o.txid IS NULL;

-- Check 3: every reversal points at a credit on the same outpoint with the opposite amount
SELECT r.entry_id, r.reverses_entry_id
FROM ledger_entries r
LEFT JOIN ledger_entries c ON c.entry_id = r.reverses_entry_id
WHERE r.entry_type = 'DEPOSIT_REVERSAL'
  AND (c.entry_id IS NULL OR c.entry_type <> 'DEPOSIT_CREDIT'
       OR c.txid <> r.txid OR c.vout <> r.vout OR c.amount_sat <> -r.amount_sat);

Check 1 returns two rows on this data: client C-1001 UNDER_CREDITED by 50,000,000 satoshis (an orphaned deposit) and the conflicted outpoint of client C-1002 OVER_CREDITED by 25,000,000 satoshis (a missing reversal, so the client holds 0.5 BTC for one 0.25 BTC deposit). Checks 2 and 3 return nothing. The same comparison belongs in daily reconciliation after release; the ledger reconciliation guide covers break classes and tolerances.

6. Assert how reversals are posted

A reversal is a new entry that references the reversed credit, with the opposite amount and the same outpoint. Tests fail any build that deletes or edits the original credit, because the position history then loses a credit the client may already have traded. A reversal that takes a balance below zero must appear in the break report with the client and outpoint. Test data stays synthetic; no production account data enters the test chain.

The pitfalls a green happy path hides

The removed array contains deposits that are still valid. Bitcoin Core 28.0 documents that transactions re-added in the active chain "appear as-is and may have positive confirmation counts" in removed. In the regtest run above, the re-mined deposit of client C-1001 appears in both arrays with 7 confirmations. A handler that reverses everything in removed and then skips the credit because the outpoint is already in its idempotency set produces the orphaned deposit from step 5.

Negative confirmations mean conflicted. The gettransaction documentation says negative confirmations mean the transaction "conflicted that many blocks ago". A handler that reverses only at 0 confirmations never reverses a replaced deposit. Since Bitcoin Core 28.0 the default of -mempoolfullrbf is 1, so an unconfirmed deposit that does not signal replaceability can still be replaced at nodes running the default.

Anvil's finalized tag is arithmetic. In the Anvil 1.8.3 source, safe is the latest block minus the slots per epoch and finalized is the latest block minus twice that, 32 and 64 blocks with the default --slots-in-an-epoch. With automine, a deposit waiting for finalized credits only after the test mines 64 more blocks with anvil_mine. The tag never stalls, so a stalled-finality test needs a proxy that pins the finalized response. anvil_reorg accepts any depth up to chain height; keep pass or fail depths below the finalized offset and treat deeper ones as incident drills.

Test matrix

ScenarioHow to create itExpected ledger resultDefect it catches
Credit at thresholdMine threshold minus 1 blocks, then 1 moreCredit appears after the last block onlyCredit one confirmation early
Re-mined deposit, Bitcoininvalidateblock, then mine a longer branchNo new entryOrphaned deposit or double credit
Replaced depositinvalidateblock, bumpfee, then mineOne reversal of the original and one credit of the replacementMissing reversal
Dropped deposit, Ethereumanvil_reorg 1 '[]'One reversal or no credit, per thresholdCredit for a transaction that no longer exists
Re-mined token depositanvil_reorg with other token transfers ahead of the raw transaction in the replacement blockNo new entryKey built on block-level logIndex
Reorg below finalizedanvil_reorg deeper than 64 blocksIncident alert and hold on affected clientsSilent reversal with no alert
Stalled finalityProxy pins the finalized blockDeposits stay pending; no fallback to latestSilent credit at a lower threshold
Reversal after withdrawalWithdraw the credit, then replace the depositNegative balance in the break reportDeleted credit or hidden shortfall

The crypto wallet testing service runs this matrix per supported chain and token in CI, next to withdrawal signing and fee tests.

Questions

What is a blockchain reorg?

A blockchain reorg, or chain reorganisation, happens when a node switches its active chain to a competing branch, so blocks it had accepted leave the chain and their transactions return to pending or disappear. Bitcoin nodes follow the valid chain with the most proof of work. On proof-of-stake Ethereum, reverting a finalized block would cost an attacker at least one third of all staked ether (ETH).

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing