Skip to content

debug_getModifiedAccountsByHash/Number omit accounts deleted between the compared blocks #35481

Description

@BenWhite713

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions