From 77a22532f1ffad8b833815276e94b0c587f412ed Mon Sep 17 00:00:00 2001 From: Mikhail Rogachev Date: Wed, 8 Apr 2026 15:34:39 +0200 Subject: [PATCH 1/5] Add eth_call filter tests and implement TxFilterer interface changes --- execution/gethexec/tx_filterer.go | 11 ++- go-ethereum | 2 +- ..._gas_filter_test.go => rpc_filter_test.go} | 86 +++++++++++++++++-- 3 files changed, 91 insertions(+), 8 deletions(-) rename system_tests/{estimate_gas_filter_test.go => rpc_filter_test.go} (50%) diff --git a/execution/gethexec/tx_filterer.go b/execution/gethexec/tx_filterer.go index cd1f6d97dda..f11b82cb8cd 100644 --- a/execution/gethexec/tx_filterer.go +++ b/execution/gethexec/tx_filterer.go @@ -24,11 +24,18 @@ func (f *txFilterer) Setup(statedb *state.StateDB) { statedb.SetTxContext(common.Hash{}, 0) } -func (f *txFilterer) TouchAddresses(statedb *state.StateDB, tx *types.Transaction, sender common.Address) { +func (f *txFilterer) TouchFromTo(statedb *state.StateDB, from common.Address, to *common.Address) { + statedb.TouchAddress(from) + if to != nil { + statedb.TouchAddress(*to) + } +} + +func (f *txFilterer) TouchScheduledTxAddresses(statedb *state.StateDB, tx *types.Transaction, sender common.Address) { touchAddresses(statedb, tx, sender) } -func (f *txFilterer) CheckFiltered(statedb *state.StateDB) error { +func (f *txFilterer) ApplyEventsAndCheckFiltered(statedb *state.StateDB) error { applyEventFilter(f.eventFilter, statedb) if statedb.IsAddressFiltered() { return state.ErrArbTxFilter diff --git a/go-ethereum b/go-ethereum index bf231100a4e..de46c2a1f86 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit bf231100a4e59852dac45a39da22f26eea975069 +Subproject commit de46c2a1f865255c2769b765713be841c09e9c4f diff --git a/system_tests/estimate_gas_filter_test.go b/system_tests/rpc_filter_test.go similarity index 50% rename from system_tests/estimate_gas_filter_test.go rename to system_tests/rpc_filter_test.go index 5c6f9656f8c..01ad6f5fe74 100644 --- a/system_tests/estimate_gas_filter_test.go +++ b/system_tests/rpc_filter_test.go @@ -12,9 +12,9 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// buildEstimateGasFilterNode creates a single sequencer node with RPC filtering -// enabled. The txFilterer is wired into the backend at construction time. -func buildEstimateGasFilterNode(t *testing.T, ctx context.Context, enableETHCallFilter bool) (builder *NodeBuilder, cleanup func()) { +// buildRPCFilterNode creates a single sequencer node with RPC filtering +// enabled or disabled. The txFilterer is wired into the backend at construction time. +func buildRPCFilterNode(t *testing.T, ctx context.Context, enableETHCallFilter bool) (builder *NodeBuilder, cleanup func()) { t.Helper() builder = NewNodeBuilder(ctx).DefaultConfig(t, false) builder.isSequencer = true @@ -29,7 +29,7 @@ func TestEstimateGasFilterDirectAddress(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - builder, cleanup := buildEstimateGasFilterNode(t, ctx, true) + builder, cleanup := buildRPCFilterNode(t, ctx, true) defer cleanup() builder.L2Info.GenerateAccount("FilteredUser") @@ -77,7 +77,7 @@ func TestEstimateGasFilterDisabled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - builder, cleanup := buildEstimateGasFilterNode(t, ctx, false) + builder, cleanup := buildRPCFilterNode(t, ctx, false) defer cleanup() builder.L2Info.GenerateAccount("FilteredUser") @@ -98,3 +98,79 @@ func TestEstimateGasFilterDisabled(t *testing.T) { }) Require(t, err) } + +// TestEthCallFilterDirectAddress verifies that eth_call rejects +// calls involving a filtered address when EnableETHCallFilter is true. +func TestEthCallFilterDirectAddress(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder, cleanup := buildRPCFilterNode(t, ctx, true) + defer cleanup() + + builder.L2Info.GenerateAccount("FilteredUser") + builder.L2Info.GenerateAccount("NormalUser") + builder.L2.TransferBalance(t, "Owner", "NormalUser", big.NewInt(1e18), builder.L2Info) + builder.L2.TransferBalance(t, "Owner", "FilteredUser", big.NewInt(1e18), builder.L2Info) + + filteredAddr := builder.L2Info.GetAddress("FilteredUser") + normalAddr := builder.L2Info.GetAddress("NormalUser") + filter := newHashedChecker([]common.Address{filteredAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, filter) + + // eth_call TO filtered address should fail + _, err := builder.L2.Client.CallContract(ctx, ethereum.CallMsg{ + From: normalAddr, + To: &filteredAddr, + Value: big.NewInt(1e12), + }, nil) + if !isFilteredError(err) { + t.Fatalf("expected filtered error for eth_call TO filtered address, got: %v", err) + } + + // eth_call FROM filtered address should fail + _, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{ + From: filteredAddr, + To: &normalAddr, + Value: big.NewInt(1e12), + }, nil) + if !isFilteredError(err) { + t.Fatalf("expected filtered error for eth_call FROM filtered address, got: %v", err) + } + + // eth_call between clean addresses should succeed + _, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{ + From: normalAddr, + To: &normalAddr, + Value: big.NewInt(1e12), + }, nil) + Require(t, err) +} + +// TestEthCallFilterDisabled verifies that eth_call does not reject +// calls to filtered addresses when EnableETHCallFilter is false. +func TestEthCallFilterDisabled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder, cleanup := buildRPCFilterNode(t, ctx, false) + defer cleanup() + + builder.L2Info.GenerateAccount("FilteredUser") + builder.L2Info.GenerateAccount("NormalUser") + builder.L2.TransferBalance(t, "Owner", "NormalUser", big.NewInt(1e18), builder.L2Info) + builder.L2.TransferBalance(t, "Owner", "FilteredUser", big.NewInt(1e18), builder.L2Info) + + filteredAddr := builder.L2Info.GetAddress("FilteredUser") + normalAddr := builder.L2Info.GetAddress("NormalUser") + filter := newHashedChecker([]common.Address{filteredAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, filter) + + // eth_call TO filtered address should succeed when RPC filter is disabled + _, err := builder.L2.Client.CallContract(ctx, ethereum.CallMsg{ + From: normalAddr, + To: &filteredAddr, + Value: big.NewInt(1e12), + }, nil) + Require(t, err) +} From 672331666502769ade78372520da63a5eaebfd3e Mon Sep 17 00:00:00 2001 From: Mikhail Rogachev Date: Thu, 9 Apr 2026 10:59:47 +0200 Subject: [PATCH 2/5] Add test to verify eth_call result with filtering is the same --- changelog/mrogachev-nit-4725.md | 2 + system_tests/rpc_filter_test.go | 89 +++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 changelog/mrogachev-nit-4725.md diff --git a/changelog/mrogachev-nit-4725.md b/changelog/mrogachev-nit-4725.md new file mode 100644 index 00000000000..5c8d77c8800 --- /dev/null +++ b/changelog/mrogachev-nit-4725.md @@ -0,0 +1,2 @@ +### Internal +- Add transaction address filtering support to eth_call, matching eth_estimateGas behavior. diff --git a/system_tests/rpc_filter_test.go b/system_tests/rpc_filter_test.go index 01ad6f5fe74..b798a5752fa 100644 --- a/system_tests/rpc_filter_test.go +++ b/system_tests/rpc_filter_test.go @@ -4,12 +4,19 @@ package arbtest import ( + "bytes" "context" "math/big" "testing" + "time" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + + "github.com/offchainlabs/nitro/arbos/l2pricing" + "github.com/offchainlabs/nitro/solgen/go/bridgegen" + "github.com/offchainlabs/nitro/solgen/go/precompilesgen" + "github.com/offchainlabs/nitro/util/arbmath" ) // buildRPCFilterNode creates a single sequencer node with RPC filtering @@ -174,3 +181,85 @@ func TestEthCallFilterDisabled(t *testing.T) { }, nil) Require(t, err) } + +// TestEthCallFilterPreservesResultWithScheduledTxes verifies that address +// filtering does not alter the eth_call result when scheduled transactions +// (retryable redeems) are involved. +func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Build node with L1 and filtering enabled + builder := NewNodeBuilder(ctx).DefaultConfig(t, true) + builder.isSequencer = true + builder.execConfig.TransactionFiltering.EnableETHCallFilter = true + cleanup := builder.Build(t) + defer cleanup() + + builder.L2Info.GenerateAccount("User") + builder.L2Info.GenerateAccount("Unrelated") + builder.L2.TransferBalance(t, "Owner", "User", big.NewInt(1e18), builder.L2Info) + + userAddr := builder.L2Info.GetAddress("User") + unrelatedAddr := builder.L2Info.GetAddress("Unrelated") + + // Submit retryable via L1 with gasLimit=0 (no auto-redeem) so the + // ticket survives for manual redeem via eth_call. + delayedInbox, err := bridgegen.NewInbox(builder.L1Info.GetAddress("Inbox"), builder.L1.Client) + Require(t, err) + + deposit := arbmath.BigMul(big.NewInt(1e12), big.NewInt(1e12)) + l1opts := builder.L1Info.GetDefaultTransactOpts("Faucet", ctx) + l1opts.Value = deposit + l1tx, err := delayedInbox.CreateRetryableTicket( + &l1opts, + userAddr, + big.NewInt(1e6), + big.NewInt(1e16), + userAddr, + userAddr, + big.NewInt(0), // gasLimit=0 → no auto-redeem + big.NewInt(l2pricing.InitialBaseFeeWei*2), + nil, + ) + Require(t, err) + l1Receipt, err := builder.L1.EnsureTxSucceeded(l1tx) + Require(t, err) + + // Extract ticket ID and wait for it on L2 + ticketId := lookupSubmissionTxHash(t, ctx, builder, l1Receipt) + AdvanceL1(t, ctx, builder.L1.Client, builder.L1Info, 30) + _, err = WaitForTx(ctx, builder.L2.Client, ticketId, 30*time.Second) + Require(t, err) + + // Craft eth_call data: ArbRetryableTx.redeem(ticketId) + arbRetryableABI, err := precompilesgen.ArbRetryableTxMetaData.GetAbi() + Require(t, err) + redeemData, err := arbRetryableABI.Pack("redeem", ticketId) + Require(t, err) + arbRetryableAddr := common.HexToAddress("0x6e") + + callMsg := ethereum.CallMsg{ + From: userAddr, + To: &arbRetryableAddr, + Data: redeemData, + } + + // eth_call WITHOUT address checker — filtering path runs but checker is nil + resultWithoutChecker, err := builder.L2.Client.CallContract(ctx, callMsg, nil) + Require(t, err) + + // Set address checker with an unrelated address (not involved in the call) + filter := newHashedChecker([]common.Address{unrelatedAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, filter) + + // eth_call WITH address checker — full filtering path with active checker + resultWithChecker, err := builder.L2.Client.CallContract(ctx, callMsg, nil) + Require(t, err) + + // Results must be identical — filtering must not alter the return value + if !bytes.Equal(resultWithoutChecker, resultWithChecker) { + t.Fatalf("eth_call results differ with filtering active:\n without checker: %x\n with checker: %x", + resultWithoutChecker, resultWithChecker) + } +} From abfe3b9f37b9d3bbe76cf0068a83b86bde3b6b18 Mon Sep 17 00:00:00 2001 From: Mikhail Rogachev Date: Thu, 9 Apr 2026 12:34:51 +0200 Subject: [PATCH 3/5] Add test case to cover nil To CallContract --- system_tests/rpc_filter_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/system_tests/rpc_filter_test.go b/system_tests/rpc_filter_test.go index b798a5752fa..8da8929b9fa 100644 --- a/system_tests/rpc_filter_test.go +++ b/system_tests/rpc_filter_test.go @@ -145,6 +145,15 @@ func TestEthCallFilterDirectAddress(t *testing.T) { t.Fatalf("expected filtered error for eth_call FROM filtered address, got: %v", err) } + // eth_call with nil To (contract creation) FROM filtered address should fail + _, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{ + From: filteredAddr, + Value: big.NewInt(1e12), + }, nil) + if !isFilteredError(err) { + t.Fatalf("expected filtered error for eth_call with nil To FROM filtered address, got: %v", err) + } + // eth_call between clean addresses should succeed _, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{ From: normalAddr, @@ -245,7 +254,7 @@ func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { Data: redeemData, } - // eth_call WITHOUT address checker — filtering path runs but checker is nil + // eth_call without address checker resultWithoutChecker, err := builder.L2.Client.CallContract(ctx, callMsg, nil) Require(t, err) @@ -253,7 +262,7 @@ func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { filter := newHashedChecker([]common.Address{unrelatedAddr}) builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, filter) - // eth_call WITH address checker — full filtering path with active checker + // eth_call with address checker resultWithChecker, err := builder.L2.Client.CallContract(ctx, callMsg, nil) Require(t, err) From bdc32c2b5f82655a97647b6e23cb88a0c1a966fb Mon Sep 17 00:00:00 2001 From: Mikhail Rogachev Date: Mon, 13 Apr 2026 18:05:28 +0200 Subject: [PATCH 4/5] Use const value for ArbRetryableTxAddress --- system_tests/rpc_filter_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/system_tests/rpc_filter_test.go b/system_tests/rpc_filter_test.go index 8da8929b9fa..e151ef1c19d 100644 --- a/system_tests/rpc_filter_test.go +++ b/system_tests/rpc_filter_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/offchainlabs/nitro/arbos/l2pricing" "github.com/offchainlabs/nitro/solgen/go/bridgegen" @@ -246,7 +247,7 @@ func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { Require(t, err) redeemData, err := arbRetryableABI.Pack("redeem", ticketId) Require(t, err) - arbRetryableAddr := common.HexToAddress("0x6e") + arbRetryableAddr := types.ArbRetryableTxAddress callMsg := ethereum.CallMsg{ From: userAddr, From fabe509746d7884f0c36d2b5ea6cbac6227a73be Mon Sep 17 00:00:00 2001 From: Mikhail Rogachev Date: Tue, 14 Apr 2026 19:37:26 +0200 Subject: [PATCH 5/5] Review fixes --- execution/gethexec/tx_filterer.go | 9 +------- go-ethereum | 2 +- system_tests/rpc_filter_test.go | 36 ++++++++++++++++++++----------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/execution/gethexec/tx_filterer.go b/execution/gethexec/tx_filterer.go index f11b82cb8cd..362db1011f5 100644 --- a/execution/gethexec/tx_filterer.go +++ b/execution/gethexec/tx_filterer.go @@ -24,14 +24,7 @@ func (f *txFilterer) Setup(statedb *state.StateDB) { statedb.SetTxContext(common.Hash{}, 0) } -func (f *txFilterer) TouchFromTo(statedb *state.StateDB, from common.Address, to *common.Address) { - statedb.TouchAddress(from) - if to != nil { - statedb.TouchAddress(*to) - } -} - -func (f *txFilterer) TouchScheduledTxAddresses(statedb *state.StateDB, tx *types.Transaction, sender common.Address) { +func (f *txFilterer) TouchAddresses(statedb *state.StateDB, tx *types.Transaction, sender common.Address) { touchAddresses(statedb, tx, sender) } diff --git a/go-ethereum b/go-ethereum index ca3c6c7761b..9fb0a86960f 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit ca3c6c7761b1d24941b400a74522cb2937717e06 +Subproject commit 9fb0a86960f9653f40e9de927f733c2006af08c6 diff --git a/system_tests/rpc_filter_test.go b/system_tests/rpc_filter_test.go index e151ef1c19d..541fb83979c 100644 --- a/system_tests/rpc_filter_test.go +++ b/system_tests/rpc_filter_test.go @@ -22,9 +22,9 @@ import ( // buildRPCFilterNode creates a single sequencer node with RPC filtering // enabled or disabled. The txFilterer is wired into the backend at construction time. -func buildRPCFilterNode(t *testing.T, ctx context.Context, enableETHCallFilter bool) (builder *NodeBuilder, cleanup func()) { +func buildRPCFilterNode(t *testing.T, ctx context.Context, enableETHCallFilter bool, withL1 bool) (builder *NodeBuilder, cleanup func()) { t.Helper() - builder = NewNodeBuilder(ctx).DefaultConfig(t, false) + builder = NewNodeBuilder(ctx).DefaultConfig(t, withL1) builder.isSequencer = true builder.execConfig.TransactionFiltering.EnableETHCallFilter = enableETHCallFilter cleanup = builder.Build(t) @@ -37,7 +37,7 @@ func TestEstimateGasFilterDirectAddress(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - builder, cleanup := buildRPCFilterNode(t, ctx, true) + builder, cleanup := buildRPCFilterNode(t, ctx, true, false) defer cleanup() builder.L2Info.GenerateAccount("FilteredUser") @@ -85,7 +85,7 @@ func TestEstimateGasFilterDisabled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - builder, cleanup := buildRPCFilterNode(t, ctx, false) + builder, cleanup := buildRPCFilterNode(t, ctx, false, false) defer cleanup() builder.L2Info.GenerateAccount("FilteredUser") @@ -113,7 +113,7 @@ func TestEthCallFilterDirectAddress(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - builder, cleanup := buildRPCFilterNode(t, ctx, true) + builder, cleanup := buildRPCFilterNode(t, ctx, true, false) defer cleanup() builder.L2Info.GenerateAccount("FilteredUser") @@ -170,7 +170,7 @@ func TestEthCallFilterDisabled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - builder, cleanup := buildRPCFilterNode(t, ctx, false) + builder, cleanup := buildRPCFilterNode(t, ctx, false, false) defer cleanup() builder.L2Info.GenerateAccount("FilteredUser") @@ -199,11 +199,7 @@ func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Build node with L1 and filtering enabled - builder := NewNodeBuilder(ctx).DefaultConfig(t, true) - builder.isSequencer = true - builder.execConfig.TransactionFiltering.EnableETHCallFilter = true - cleanup := builder.Build(t) + builder, cleanup := buildRPCFilterNode(t, ctx, true, true) defer cleanup() builder.L2Info.GenerateAccount("User") @@ -255,8 +251,13 @@ func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { Data: redeemData, } + // Pin all eth_calls to the same block to avoid flakiness from state changes + blockNum, err := builder.L2.Client.BlockNumber(ctx) + Require(t, err) + block := new(big.Int).SetUint64(blockNum) + // eth_call without address checker - resultWithoutChecker, err := builder.L2.Client.CallContract(ctx, callMsg, nil) + resultWithoutChecker, err := builder.L2.Client.CallContract(ctx, callMsg, block) Require(t, err) // Set address checker with an unrelated address (not involved in the call) @@ -264,7 +265,7 @@ func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, filter) // eth_call with address checker - resultWithChecker, err := builder.L2.Client.CallContract(ctx, callMsg, nil) + resultWithChecker, err := builder.L2.Client.CallContract(ctx, callMsg, block) Require(t, err) // Results must be identical — filtering must not alter the return value @@ -272,4 +273,13 @@ func TestEthCallFilterPreservesResultWithScheduledTxes(t *testing.T) { t.Fatalf("eth_call results differ with filtering active:\n without checker: %x\n with checker: %x", resultWithoutChecker, resultWithChecker) } + + // Set address checker to filter userAddr + filter = newHashedChecker([]common.Address{userAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, filter) + + _, err = builder.L2.Client.CallContract(ctx, callMsg, block) + if !isFilteredError(err) { + t.Fatalf("expected filtered error for eth_call with filtered retryable address, got: %v", err) + } }