From df2a91fb0a581254f0fda7823c3285854ea57094 Mon Sep 17 00:00:00 2001 From: CPerezz Date: Tue, 3 Feb 2026 12:02:54 +0100 Subject: [PATCH] ethapi: add partial state awareness to RPC layer (Phase 4) Add partial state mode support to the RPC API. In partial state mode: - Account queries (balance, nonce, account proofs) work for ALL accounts - Storage/code queries only work for tracked contracts - Clear error codes help clients understand limitations Changes: - New error types: StorageNotTrackedError (-32001), CodeNotTrackedError (-32002) - Backend interface: PartialStateEnabled(), IsContractTracked() - Modified RPCs: GetStorageAt, GetCode, GetProof check tracked status - 7 new tests verify correct behavior for tracked/untracked contracts --- eth/api_backend.go | 18 +++ internal/ethapi/api.go | 15 ++ internal/ethapi/api_test.go | 279 ++++++++++++++++++++++++++++++++++++ internal/ethapi/backend.go | 4 + internal/ethapi/errors.go | 30 ++++ 5 files changed, 346 insertions(+) diff --git a/eth/api_backend.go b/eth/api_backend.go index 3f3d819213..e7a3385871 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -519,3 +519,21 @@ func (b *EthAPIBackend) BlockAccessListByNumberOrHash(number rpc.BlockNumberOrHa } return block.AccessList().StringableRepresentation(), nil } + +// PartialStateEnabled returns true if partial state mode is active. +func (b *EthAPIBackend) PartialStateEnabled() bool { + return b.eth.config.PartialState.Enabled +} + +// IsContractTracked returns true if the contract's storage is tracked. +// For full nodes (partial state disabled), this always returns true. +func (b *EthAPIBackend) IsContractTracked(addr common.Address) bool { + if !b.eth.config.PartialState.Enabled { + return true // Full node tracks everything + } + ps := b.eth.blockchain.PartialState() + if ps == nil { + return true // Shouldn't happen if config says enabled, but be safe + } + return ps.Filter().IsTracked(addr) +} diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index b0a8d6df4d..e4eba17174 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -374,6 +374,11 @@ func (api *BlockChainAPI) GetProof(ctx context.Context, address common.Address, keyLengths = make([]int, len(storageKeys)) storageProof = make([]StorageResult, len(storageKeys)) ) + // In partial state mode, storage proofs are only available for tracked contracts. + // Account proofs work for ALL accounts since we have the full account trie. + if len(storageKeys) > 0 && api.b.PartialStateEnabled() && !api.b.IsContractTracked(address) { + return nil, &StorageNotTrackedError{Address: address} + } // Deserialize all keys. This prevents state access on invalid input. for i, hexKey := range storageKeys { var err error @@ -579,6 +584,12 @@ func (api *BlockChainAPI) GetUncleCountByBlockHash(ctx context.Context, blockHas // GetCode returns the code stored at the given address in the state for the given block number. func (api *BlockChainAPI) GetCode(ctx context.Context, address common.Address, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + // Check if code is available for this contract in partial state mode + // Note: Account code hash is available for all accounts, but actual bytecode + // is only stored for tracked contracts in partial state mode. + if api.b.PartialStateEnabled() && !api.b.IsContractTracked(address) { + return nil, &CodeNotTrackedError{Address: address} + } state, _, err := api.b.StateAndHeaderByNumberOrHash(ctx, blockNrOrHash) if state == nil || err != nil { return nil, err @@ -591,6 +602,10 @@ func (api *BlockChainAPI) GetCode(ctx context.Context, address common.Address, b // block number. The rpc.LatestBlockNumber and rpc.PendingBlockNumber meta block // numbers are also allowed. func (api *BlockChainAPI) GetStorageAt(ctx context.Context, address common.Address, hexKey string, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + // Check if storage is available for this contract in partial state mode + if api.b.PartialStateEnabled() && !api.b.IsContractTracked(address) { + return nil, &StorageNotTrackedError{Address: address} + } state, _, err := api.b.StateAndHeaderByNumberOrHash(ctx, blockNrOrHash) if state == nil || err != nil { return nil, err diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index a80d6a62d6..d5276d514f 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -3979,6 +3979,12 @@ func (b *testBackend) RPCTxSyncMaxTimeout() time.Duration { func (b *backendMock) RPCTxSyncDefaultTimeout() time.Duration { return 2 * time.Second } func (b *backendMock) RPCTxSyncMaxTimeout() time.Duration { return 5 * time.Minute } +// Partial state awareness methods - test backends behave as full nodes +func (b *testBackend) PartialStateEnabled() bool { return false } +func (b *testBackend) IsContractTracked(addr common.Address) bool { return true } +func (b *backendMock) PartialStateEnabled() bool { return false } +func (b *backendMock) IsContractTracked(addr common.Address) bool { return true } + func makeSignedRaw(t *testing.T, api *TransactionAPI, from, to common.Address, value *big.Int) (hexutil.Bytes, *types.Transaction) { t.Helper() @@ -4157,3 +4163,276 @@ func TestGetStorageValues(t *testing.T) { t.Fatal("expected error for exceeding slot limit") } } + +// ============================================================================ +// Partial State Mode Tests +// ============================================================================ + +// partialStateTestBackend wraps a testBackend to simulate partial state mode. +// It tracks a specific set of contracts and returns errors for untracked ones. +type partialStateTestBackend struct { + *testBackend + trackedContracts map[common.Address]struct{} +} + +func newPartialStateTestBackend(tb *testBackend, tracked []common.Address) *partialStateTestBackend { + m := make(map[common.Address]struct{}, len(tracked)) + for _, addr := range tracked { + m[addr] = struct{}{} + } + return &partialStateTestBackend{ + testBackend: tb, + trackedContracts: m, + } +} + +func (b *partialStateTestBackend) PartialStateEnabled() bool { + return true +} + +func (b *partialStateTestBackend) IsContractTracked(addr common.Address) bool { + _, ok := b.trackedContracts[addr] + return ok +} + +func TestPartialState_GetStorageAt_UntrackedContract(t *testing.T) { + t.Parallel() + + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + common.HexToAddress("0x1111111111111111111111111111111111111111"): { + Balance: big.NewInt(1000000000), + Storage: map[common.Hash]common.Hash{ + common.HexToHash("0x0"): common.HexToHash("0x42"), + }, + }, + }, + } + tb := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + + // Create partial state backend with no tracked contracts + b := newPartialStateTestBackend(tb, nil) + api := NewBlockChainAPI(b) + + // Query storage for untracked contract should fail + untrackedAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + _, err := api.GetStorageAt(context.Background(), untrackedAddr, "0x0", rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + + if err == nil { + t.Fatal("expected error for untracked contract storage") + } + + var storageErr *StorageNotTrackedError + if !errors.As(err, &storageErr) { + t.Fatalf("expected StorageNotTrackedError, got %T: %v", err, err) + } + if storageErr.Address != untrackedAddr { + t.Errorf("expected address %s, got %s", untrackedAddr.Hex(), storageErr.Address.Hex()) + } + if storageErr.ErrorCode() != errCodeStorageNotTracked { + t.Errorf("expected error code %d, got %d", errCodeStorageNotTracked, storageErr.ErrorCode()) + } +} + +func TestPartialState_GetStorageAt_TrackedContract(t *testing.T) { + t.Parallel() + + trackedAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + expectedValue := common.HexToHash("0x42") + + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + trackedAddr: { + Balance: big.NewInt(1000000000), + Storage: map[common.Hash]common.Hash{ + common.HexToHash("0x0"): expectedValue, + }, + }, + }, + } + tb := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + + // Create partial state backend with the contract tracked + b := newPartialStateTestBackend(tb, []common.Address{trackedAddr}) + api := NewBlockChainAPI(b) + + // Query storage for tracked contract should succeed + result, err := api.GetStorageAt(context.Background(), trackedAddr, "0x0", rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if common.BytesToHash(result) != expectedValue { + t.Errorf("expected value %s, got %s", expectedValue.Hex(), common.BytesToHash(result).Hex()) + } +} + +func TestPartialState_GetCode_UntrackedContract(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + contractAddr: { + Balance: big.NewInt(1000000000), + Code: []byte{0x60, 0x00}, // PUSH1 0x00 + }, + }, + } + tb := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + + // Create partial state backend with no tracked contracts + b := newPartialStateTestBackend(tb, nil) + api := NewBlockChainAPI(b) + + // Query code for untracked contract should fail + _, err := api.GetCode(context.Background(), contractAddr, rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + + if err == nil { + t.Fatal("expected error for untracked contract code") + } + + var codeErr *CodeNotTrackedError + if !errors.As(err, &codeErr) { + t.Fatalf("expected CodeNotTrackedError, got %T: %v", err, err) + } + if codeErr.ErrorCode() != errCodeCodeNotTracked { + t.Errorf("expected error code %d, got %d", errCodeCodeNotTracked, codeErr.ErrorCode()) + } +} + +func TestPartialState_GetCode_TrackedContract(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + expectedCode := []byte{0x60, 0x00} // PUSH1 0x00 + + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + contractAddr: { + Balance: big.NewInt(1000000000), + Code: expectedCode, + }, + }, + } + tb := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + + // Create partial state backend with the contract tracked + b := newPartialStateTestBackend(tb, []common.Address{contractAddr}) + api := NewBlockChainAPI(b) + + // Query code for tracked contract should succeed + result, err := api.GetCode(context.Background(), contractAddr, rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !bytes.Equal(result, expectedCode) { + t.Errorf("expected code %x, got %x", expectedCode, result) + } +} + +func TestPartialState_GetProof_AccountOnly(t *testing.T) { + t.Parallel() + + // Any account should work for account-only proofs (no storage keys) + accountAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accountAddr: { + Balance: big.NewInt(1000000000), + }, + }, + } + tb := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + + // Create partial state backend with no tracked contracts + b := newPartialStateTestBackend(tb, nil) + api := NewBlockChainAPI(b) + + // Account-only proof should succeed even for untracked addresses + result, err := api.GetProof(context.Background(), accountAddr, nil, rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Address != accountAddr { + t.Errorf("expected address %s, got %s", accountAddr.Hex(), result.Address.Hex()) + } + if result.Balance.ToInt().Cmp(big.NewInt(1000000000)) != 0 { + t.Errorf("expected balance 1000000000, got %s", result.Balance.String()) + } +} + +func TestPartialState_GetProof_StorageKeysUntracked(t *testing.T) { + t.Parallel() + + accountAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + accountAddr: { + Balance: big.NewInt(1000000000), + Storage: map[common.Hash]common.Hash{ + common.HexToHash("0x0"): common.HexToHash("0x42"), + }, + }, + }, + } + tb := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + + // Create partial state backend with no tracked contracts + b := newPartialStateTestBackend(tb, nil) + api := NewBlockChainAPI(b) + + // Proof with storage keys should fail for untracked contracts + _, err := api.GetProof(context.Background(), accountAddr, []string{"0x0"}, rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + if err == nil { + t.Fatal("expected error for storage proof on untracked contract") + } + + var storageErr *StorageNotTrackedError + if !errors.As(err, &storageErr) { + t.Fatalf("expected StorageNotTrackedError, got %T: %v", err, err) + } +} + +func TestPartialState_GetProof_StorageKeysTracked(t *testing.T) { + t.Parallel() + + trackedAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + genesis := &core.Genesis{ + Config: params.TestChainConfig, + Alloc: types.GenesisAlloc{ + trackedAddr: { + Balance: big.NewInt(1000000000), + Storage: map[common.Hash]common.Hash{ + common.HexToHash("0x0"): common.HexToHash("0x42"), + }, + }, + }, + } + tb := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + + // Create partial state backend with the contract tracked + b := newPartialStateTestBackend(tb, []common.Address{trackedAddr}) + api := NewBlockChainAPI(b) + + // Proof with storage keys should succeed for tracked contracts + result, err := api.GetProof(context.Background(), trackedAddr, []string{"0x0"}, rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.StorageProof) != 1 { + t.Fatalf("expected 1 storage proof, got %d", len(result.StorageProof)) + } + if result.StorageProof[0].Value.ToInt().Cmp(big.NewInt(0x42)) != 0 { + t.Errorf("expected storage value 0x42, got %s", result.StorageProof[0].Value.String()) + } +} diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index 222a0da479..4f48826999 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -92,6 +92,10 @@ type Backend interface { Engine() consensus.Engine HistoryPruningCutoff() uint64 + // Partial state awareness + PartialStateEnabled() bool // returns true if partial state mode is active + IsContractTracked(addr common.Address) bool // returns true if contract storage is tracked + // This is copied from filters.Backend // eth/filters needs to be initialized from this backend type, so methods needed by // it must also be included here. diff --git a/internal/ethapi/errors.go b/internal/ethapi/errors.go index cc79af6f3c..442aff91fc 100644 --- a/internal/ethapi/errors.go +++ b/internal/ethapi/errors.go @@ -172,6 +172,36 @@ func (e *invalidBlockTimestampError) ErrorCode() int { return errCodeBlockTimest type blockGasLimitReachedError struct{ message string } +// Partial state error codes per EIP-7928 / partial statefulness spec +const ( + errCodeStorageNotTracked = -32001 + errCodeCodeNotTracked = -32002 +) + +// StorageNotTrackedError is returned when querying storage for a contract +// that is not tracked in partial statefulness mode. +type StorageNotTrackedError struct { + Address common.Address +} + +func (e *StorageNotTrackedError) Error() string { + return fmt.Sprintf("storage not tracked for contract %s", e.Address.Hex()) +} + +func (e *StorageNotTrackedError) ErrorCode() int { return errCodeStorageNotTracked } + +// CodeNotTrackedError is returned when querying bytecode for a contract +// that is not tracked in partial statefulness mode. +type CodeNotTrackedError struct { + Address common.Address +} + +func (e *CodeNotTrackedError) Error() string { + return fmt.Sprintf("code not tracked for contract %s", e.Address.Hex()) +} + +func (e *CodeNotTrackedError) ErrorCode() int { return errCodeCodeNotTracked } + func (e *blockGasLimitReachedError) Error() string { return e.message } func (e *blockGasLimitReachedError) ErrorCode() int { return errCodeBlockGasLimitReached }