Fix: Geth debug_getModifiedAccounts Omits Deleted Accounts
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
- Fallback to
diffTracer: Avoid rawdebug_getModifiedAccountsfor mission-critical accounting. Usedebug_traceBlockByHashwithtracer: "prestateTracer", tracerConfig: { diffMode: true }to explicitly capture deletions (deleted: true). - 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.
- 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
trie.NewDifferenceIterator(oldIt, newIt)correctly steps through keys that differ between the two Merkle Patricia Tries.- For an account deleted in the second block, the key exists in
oldTrie, but has been pruned/removed fromnewTrie. - The method calls
newTrie.GetKey(key)to resolve the pre-image address from the secure hash key. - Because the node no longer exists in
newTrie,newTrie.GetKeyreturns an empty slice, and the deleted account address is silently dropped from the final RPC response.
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
postwith empty/zero state: Indicates modified or emptied accounts. - Accounts present in
prebut missing inpost: Represents destroyed/deleted accounts. This guarantees 100% accounting accuracy across blockchain reorganizations and state updates.