Summary
The documentation for both methods 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.
Both endpoints call getModifiedAccounts, which uses trie.NewDifferenceIterator(oldIt, newIt) and resolves every returned key through newTrie.GetKey. This directional difference reports new-side leaves that differ from the old trie, but it does not report an account that exists only in the old trie.
A deterministic pre-Shanghai fixture created one deleted account and one surviving modified account in the same block. Both public methods returned the surviving account but omitted the deleted account.
Documentation: https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debug-getmodifiedaccountsbyhash
Affected revision and environment
- Fixture reproduction: commit
81ab8b594ebe0f672450779d4b308f3f33191828
- Platform: macOS arm64, Go
1.26.3
- State fixture:
params.TestChainConfig with ShanghaiTime == nil, hash-based state with trie preimages
- Reproduced again on current
master at 7e520c43104fd0447acd7372a63ba1daa5c05929 (2026-08-08).
Steps to reproduce
Add the following test file as eth/api_debug_deleted_fixture_test.go:
Reproducer
package eth
import (
"math/big"
"slices"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/params"
)
func TestGetModifiedAccountsOmitsDeletedAccount(t *testing.T) {
accounts := newAccounts(3)
victim := common.HexToAddress("0x000000000000000000000000000000000000dEaD")
beneficiary := accounts[2].addr
// Pre-Shanghai runtime: PUSH20 beneficiary; SELFDESTRUCT.
code := append([]byte{byte(vm.PUSH20)}, beneficiary.Bytes()...)
code = append(code, byte(vm.SELFDESTRUCT))
genesis := &core.Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{
accounts[0].addr: {Balance: big.NewInt(params.Ether)},
accounts[1].addr: {Balance: big.NewInt(params.Ether)},
accounts[2].addr: {Balance: big.NewInt(params.Ether)},
victim: {Balance: big.NewInt(12345), Code: code},
},
}
signer := types.HomesteadSigner{}
chain := newTestBlockChain(t, 1, genesis, func(_ int, b *core.BlockGen) {
destroy, err := types.SignTx(types.NewTx(&types.LegacyTx{
Nonce: 0, To: &victim, Value: big.NewInt(0),
Gas: 100_000, GasPrice: b.BaseFee(),
}), signer, accounts[0].key)
if err != nil {
t.Fatal(err)
}
b.AddTx(destroy)
// Positive control: an account that survives but changes.
modify, err := types.SignTx(types.NewTx(&types.LegacyTx{
Nonce: 0, To: &beneficiary, Value: big.NewInt(1000),
Gas: params.TxGas, GasPrice: b.BaseFee(),
}), signer, accounts[1].key)
if err != nil {
t.Fatal(err)
}
b.AddTx(modify)
})
defer chain.Stop()
start := chain.GetHeaderByNumber(0)
end := chain.GetHeaderByNumber(1)
oldState, err := chain.StateAt(start)
if err != nil {
t.Fatal(err)
}
newState, err := chain.StateAt(end)
if err != nil {
t.Fatal(err)
}
if !oldState.Exist(victim) || newState.Exist(victim) {
t.Fatalf("fixture did not create an old-only account")
}
api := NewDebugAPI(&Ethereum{blockchain: chain})
endNumber := uint64(1)
byNumber, err := api.GetModifiedAccountsByNumber(0, &endNumber)
if err != nil {
t.Fatal(err)
}
startHash, endHash := start.Hash(), end.Hash()
byHash, err := api.GetModifiedAccountsByHash(startHash, &endHash)
if err != nil {
t.Fatal(err)
}
survivor := accounts[1].addr
for method, got := range map[string][]common.Address{
"debug_getModifiedAccountsByNumber": byNumber,
"debug_getModifiedAccountsByHash": byHash,
} {
if !slices.Contains(got, survivor) {
t.Fatalf("%s omitted positive-control survivor %s: %v", method, survivor, got)
}
if slices.Contains(got, victim) {
t.Fatalf("%s unexpectedly returned deleted account %s", method, victim)
}
t.Logf("%s returned survivor=%v deleted-account=%v",
method, slices.Contains(got, survivor), slices.Contains(got, victim))
}
}
Run:
go test ./eth -run '^TestGetModifiedAccountsOmitsDeletedAccount$' -count=1 -v
Observed behavior
The fixture established:
old state contains 0x000000000000000000000000000000000000dEaD: true
new state contains 0x000000000000000000000000000000000000dEaD: false
For both public methods:
surviving modified account returned: true
deleted account returned: false
The positive-control account shows that the API, state roots, trie preimages, and difference iterator were functioning; the omission is specific to the old-only account.
Expected behavior
An account that exists in the start state and is absent in the end state has changed between the two blocks. It should be included in the result promised as “all accounts that have changed.”
Relevant code
Impact
Tools using these endpoints as a complete state-delta source can miss deleted accounts. This can produce incomplete state audits, migration inventories, or forensic change sets.
Suggested direction
Compute a symmetric account-leaf difference rather than only new - old. One possible implementation is to combine both directional differences, recover old-only keys through oldTrie, and deduplicate addresses that appear in both directions.
Summary
The documentation for both methods states:
Both endpoints call
getModifiedAccounts, which usestrie.NewDifferenceIterator(oldIt, newIt)and resolves every returned key throughnewTrie.GetKey. This directional difference reports new-side leaves that differ from the old trie, but it does not report an account that exists only in the old trie.A deterministic pre-Shanghai fixture created one deleted account and one surviving modified account in the same block. Both public methods returned the surviving account but omitted the deleted account.
Documentation: https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debug-getmodifiedaccountsbyhash
Affected revision and environment
81ab8b594ebe0f672450779d4b308f3f331918281.26.3params.TestChainConfigwithShanghaiTime == nil, hash-based state with trie preimagesmasterat7e520c43104fd0447acd7372a63ba1daa5c05929(2026-08-08).Steps to reproduce
Add the following test file as
eth/api_debug_deleted_fixture_test.go:Reproducer
Run:
Observed behavior
The fixture established:
For both public methods:
The positive-control account shows that the API, state roots, trie preimages, and difference iterator were functioning; the omission is specific to the old-only account.
Expected behavior
An account that exists in the start state and is absent in the end state has changed between the two blocks. It should be included in the result promised as “all accounts that have changed.”
Relevant code
eth/api_debug.go:272-360: both public methods use the same directional difference and recover addresses only fromnewTrie.trie/iterator.go:628-715:differenceIteratoryields elements from its second iterator that differ from the first.Impact
Tools using these endpoints as a complete state-delta source can miss deleted accounts. This can produce incomplete state audits, migration inventories, or forensic change sets.
Suggested direction
Compute a symmetric account-leaf difference rather than only
new - old. One possible implementation is to combine both directional differences, recover old-only keys througholdTrie, and deduplicate addresses that appear in both directions.