LayerZeroFault
ai agents-api

Fix: Geth debug_getModifiedAccounts Omits Deleted Accounts (Trie Diff Fix)

VV

Written by

Fact-Checked on August 19, 2026

Verified Expert

Fix: Geth debug_getModifiedAccounts Omits Deleted Accounts

Placeholder: Trie state difference diagram illustrating old State Trie vs new State Trie showing the directional iterator skipping deleted account nodes

Blockchain data engineers, Subgraph indexers, and ETL pipeline operators relying on Go-Ethereum’s debugging namespace often face severe state reconciliation discrepancies:

// Calling debug_getModifiedAccountsByHash across a block containing contract selfdestructs:
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "debug_getModifiedAccountsByHash",
  "params": ["0xOldStateBlockHash...", "0xNewStateBlockHash..."]
}

// Result: Returns modified accounts, but SILENTLY OMITS accounts deleted or purged during block execution!

When an account is destroyed via SELFDESTRUCT (on historical blocks) or emptied via zero-balance state cleaning, debug_getModifiedAccountsByHash and debug_getModifiedAccountsByNumber fail to report the account address. This causes downstream databases and balance trackers to retain stale balances indefinitely.


Immediate 3-Step Workaround for Indexers

  1. Fallback to diffTracer: Avoid raw debug_getModifiedAccounts for mission-critical accounting. Use debug_traceBlockByHash with tracer: "prestateTracer", tracerConfig: { diffMode: true } to explicitly capture deletions (deleted: true).
  2. Review Sibling Node Crash Workarounds: If your Geth node experiences unstable RPC worker lifecycles, check our guide on Geth BlobPool.Cache Nil Pointer Dereference Panic Fix.
  3. Patch State Difference Iterators: Apply the bidirectional difference iterator in custom Geth builds to restore complete account lifecycle parity.

Technical Root Cause: Directional newTrie Difference Iteration

The Go-Ethereum documentation states:

“Returns all accounts that have changed between the two blocks specified. A change is defined as a difference in nonce, balance, code hash, or storage hash.”

However, in the Geth codebase (internal/ethapi/api.go and core/rawdb/accessors_state.go), the internal implementation of getModifiedAccounts processes state differences unidirectionally:

// Vulnerable Implementation in ethapi/api.go
func getModifiedAccounts(oldTrie, newTrie state.Trie) ([]common.Address, error) {
    diff := trie.NewDifferenceIterator(oldTrie.NodeIterator(nil), newTrie.NodeIterator(nil))
    var addresses []common.Address

    for diff.Next(true) {
        key := diff.Key()
        // BUG: Looks up key exclusively in newTrie!
        // If the account was deleted in newTrie, GetKey returns empty/nil and is discarded.
        if addr := newTrie.GetKey(key); len(addr) > 0 {
            addresses = append(addresses, common.BytesToAddress(addr))
        }
    }
    return addresses, nil
}

The Mechanism of Omission

  1. trie.NewDifferenceIterator(oldIt, newIt) correctly steps through keys that differ between the two Merkle Patricia Tries.
  2. For an account deleted in the second block, the key exists in oldTrie, but has been pruned/removed from newTrie.
  3. The method calls newTrie.GetKey(key) to resolve the pre-image address from the secure hash key.
  4. Because the node no longer exists in newTrie, newTrie.GetKey returns an empty slice, and the deleted account address is silently dropped from the final RPC response.

Placeholder: Block trace sequence diagram comparing debug_getModifiedAccounts output vs prestateTracer diffMode output


Permanent Patch: Bidirectional Pre-Image Resolution

To fix this behavior in private execution clients or custom node forks, modify the key resolution logic to check both newTrie and oldTrie pre-images:

// Patched Bidirectional Resolution in getModifiedAccounts
func getModifiedAccounts(oldTrie, newTrie state.Trie) ([]common.Address, error) {
    diff := trie.NewDifferenceIterator(oldTrie.NodeIterator(nil), newTrie.NodeIterator(nil))
    var addresses []common.Address
    seen := make(map[common.Address]bool)

    for diff.Next(true) {
        key := diff.Key()
        var addrBytes []byte

        // Check new trie first (for modified or created accounts)
        addrBytes = newTrie.GetKey(key)
        
        // If missing in new trie, resolve pre-image from old trie (for deleted accounts)
        if len(addrBytes) == 0 {
            addrBytes = oldTrie.GetKey(key)
        }

        if len(addrBytes) == common.AddressLength {
            addr := common.BytesToAddress(addrBytes)
            if !seen[addr] {
                seen[addr] = true
                addresses = append(addresses, addr)
            }
        }
    }
    return addresses, nil
}

Alternative Indexing Strategy: Prestate Diff Tracer

For indexers running standard upstream Geth nodes who cannot modify Go source code, update your ETL RPC extractor to query debug_traceBlockByHash:

curl -X POST http://localhost:8545 \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "debug_traceBlockByHash",
    "params": [
      "0xBlockWithAccountDeletions...",
      {
        "tracer": "prestateTracer",
        "tracerConfig": { "diffMode": true }
      }
    ],
    "id": 1
  }'

Parsing the Diff Response:

In the response payload, look at the post and pre objects:

  • Accounts under post with empty/zero state: Indicates modified or emptied accounts.
  • Accounts present in pre but missing in post: Represents destroyed/deleted accounts. This guarantees 100% accounting accuracy across blockchain reorganizations and state updates.
Partner Spotlight: Gate.io

Trade Securely on Gate.io

Don't risk your assets on centralized silos or unverified endpoints. Trade securely on Gate.io with deep liquidity and institutional-grade security protocols.

Claim $100 Sign-up Bonus

Official Partner Referral Link

Related Inquiries

Why does debug_getModifiedAccounts miss accounts deleted between blocks?

Both `debug_getModifiedAccountsByHash` and `debug_getModifiedAccountsByNumber` call `trie.NewDifferenceIterator(oldIt, newIt)` and look up keys exclusively in `newTrie.GetKey`. This is a one-way directional difference that ignores accounts existing only in the old state trie.

How does this bug impact blockchain indexers and analytics ETL pipelines?

Indexers rely on modified account endpoints to track balance, nonce, and code hash changes. When deleted contracts or zeroed accounts are skipped, downstream SQL databases or Subgraph caches retain stale balances, causing state desynchronization.

How can indexers work around this bug without patching Geth binaries?

You can query the block's transaction receipts and state traces (`debug_traceBlockByHash` with `prestateTracer` or `diffTracer`) to explicitly capture account destruction events and zero balance states.

What is the permanent Go-level fix for Geth node maintainers?

Implement a bidirectional dual-trie difference iterator that passes both `oldTrie.GetKey` and `newTrie.GetKey` to detect deletions (accounts with key present in old trie but missing in new trie) alongside modifications.