diff --git a/contracts-local/src/mocks/AddressFilterTest.sol b/contracts-local/src/mocks/AddressFilterTest.sol index 7fae7853a99..66220793ef6 100644 --- a/contracts-local/src/mocks/AddressFilterTest.sol +++ b/contracts-local/src/mocks/AddressFilterTest.sol @@ -123,4 +123,47 @@ contract AddressFilterTest { ) external { emit UnfilteredEvent(some); } + + /// @notice Makes a DELEGATECALL to the target address + function delegatecallTarget( + address target + ) external returns (bool success) { + (success,) = target.delegatecall(""); + emit CallResult(success); + } + + /// @notice Makes a CALLCODE to the target address (requires inline assembly) + function callcodeTarget( + address target + ) external returns (bool success) { + assembly { + success := callcode(gas(), target, 0, 0, 0, 0, 0) + } + emit CallResult(success); + } + + /// @notice Calls an intermediary contract's callTarget function with the final target. + /// This creates a two-hop call chain: this -> intermediary.callTarget(target) + function callVia(address intermediary, address target) external returns (bool success) { + bytes memory data = abi.encodeWithSignature("callTarget(address)", target); + (success,) = intermediary.call(data); + emit CallResult(success); + } + + /// @notice Accept ETH transfers + receive() external payable {} + + /// @notice Send this contract's entire balance to the target address + function payTo(address payable target) external { + (bool success,) = target.call{value: address(this).balance}(""); + require(success, "payTo failed"); + } + + /// @notice Forward msg.value to intermediary and have it pay the final target. + /// This creates a two-hop payment: caller -> intermediary -> target + function payVia(address intermediary, address payable target) external payable { + bytes memory data = abi.encodeWithSignature("payTo(address)", target); + (bool success,) = intermediary.call{value: msg.value}(data); + require(success, "payVia failed"); + } } diff --git a/system_tests/tx_address_filter_test.go b/system_tests/tx_address_filter_test.go index d3a7e99c3b3..f87c356b8ae 100644 --- a/system_tests/tx_address_filter_test.go +++ b/system_tests/tx_address_filter_test.go @@ -393,6 +393,279 @@ func TestAddressFilterSelfdestructOnConstruct(t *testing.T) { Require(t, err) } +func TestAddressFilterIndirectPayment(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false) + builder.isSequencer = true + cleanup := builder.Build(t) + defer cleanup() + + // Deploy payer contract + _, payer := deployAddressFilterTestContract(t, ctx, builder) + + // Deploy intermediary contract and fund it so it can forward payments + intermediaryAddr, _ := deployAddressFilterTestContract(t, ctx, builder) + + // Create filtered destination + builder.L2Info.GenerateAccount("FilteredDest") + filteredAddr := builder.L2Info.GetAddress("FilteredDest") + + // Create clean destination for the positive test + builder.L2Info.GenerateAccount("CleanDest") + cleanAddr := builder.L2Info.GetAddress("CleanDest") + + // Set up filter to block FilteredDest + filter := newHashedChecker([]common.Address{filteredAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(filter) + + // Test 1: Indirect payment payer -> intermediary -> filtered address should fail + auth := builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + auth.Value = big.NewInt(1e15) + _, err := payer.PayVia(&auth, intermediaryAddr, filteredAddr) + if err == nil { + t.Fatal("expected indirect payment to filtered address to be rejected") + } + if !isFilteredError(err) { + t.Fatalf("expected filtered error, got: %v", err) + } + + // Test 2: Indirect payment payer -> intermediary -> clean address should succeed + auth = builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + auth.Value = big.NewInt(1e15) + tx, err := payer.PayVia(&auth, intermediaryAddr, cleanAddr) + Require(t, err) + _, err = builder.L2.EnsureTxSucceeded(tx) + Require(t, err) + + // Verify the clean destination received the funds + balance := builder.L2.GetBalance(t, cleanAddr) + if balance.Cmp(big.NewInt(1e15)) != 0 { + t.Fatalf("expected clean destination balance of 1e15, got %s", balance.String()) + } +} + +// TestAddressFilterDelegateCall verifies that DELEGATECALL to a filtered address +// does NOT trigger filtering. DELEGATECALL loads code from the target but executes +// in the caller's context, so the target address is never "entered" from the +// filter's perspective (PushContract sees caller, not the code source). +func TestAddressFilterDelegateCall(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false) + builder.isSequencer = true + cleanup := builder.Build(t) + defer cleanup() + + // Deploy caller contract (not filtered) + _, caller := deployAddressFilterTestContract(t, ctx, builder) + + // Deploy target contract (will be filtered) + targetAddr, _ := deployAddressFilterTestContract(t, ctx, builder) + + // Set up filter to block the target contract + filter := newHashedChecker([]common.Address{targetAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(filter) + + // DELEGATECALL to filtered address should succeed because the target is + // only a code source - execution stays in the caller's context. + auth := builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + tx, err := caller.DelegatecallTarget(&auth, targetAddr) + Require(t, err) + _, err = builder.L2.EnsureTxSucceeded(tx) + Require(t, err) + + // Sanity check: a regular CALL to the same filtered address should still fail + auth = builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + _, err = caller.CallTarget(&auth, targetAddr) + if err == nil { + t.Fatal("expected CALL to filtered address to be rejected") + } + if !isFilteredError(err) { + t.Fatalf("expected filtered error, got: %v", err) + } +} + +// TestAddressFilterCallCode verifies that CALLCODE to a filtered address +// does NOT trigger filtering, for the same reason as DELEGATECALL: the target +// address is only used as a code source. +func TestAddressFilterCallCode(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false) + builder.isSequencer = true + cleanup := builder.Build(t) + defer cleanup() + + // Deploy caller contract (not filtered) + _, caller := deployAddressFilterTestContract(t, ctx, builder) + + // Deploy target contract (will be filtered) + targetAddr, _ := deployAddressFilterTestContract(t, ctx, builder) + + // Set up filter to block the target contract + filter := newHashedChecker([]common.Address{targetAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(filter) + + // CALLCODE to filtered address should succeed + auth := builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + tx, err := caller.CallcodeTarget(&auth, targetAddr) + Require(t, err) + _, err = builder.L2.EnsureTxSucceeded(tx) + Require(t, err) +} + +// TestAddressFilterCallViaFilteredIntermediary verifies that when a non-filtered +// contract CALLs a filtered intermediary, the transaction is rejected even though +// the final target is not filtered. +func TestAddressFilterCallViaFilteredIntermediary(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false) + builder.isSequencer = true + cleanup := builder.Build(t) + defer cleanup() + + // Deploy payer contract (not filtered) + _, payer := deployAddressFilterTestContract(t, ctx, builder) + + // Deploy intermediary contract (will be filtered) + intermediaryAddr, _ := deployAddressFilterTestContract(t, ctx, builder) + + // Deploy final target contract (not filtered) + targetAddr, _ := deployAddressFilterTestContract(t, ctx, builder) + + // Filter only the intermediary + filter := newHashedChecker([]common.Address{intermediaryAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(filter) + + // Test: payer -> filtered intermediary -> target should fail at the intermediary + auth := builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + _, err := payer.CallVia(&auth, intermediaryAddr, targetAddr) + if err == nil { + t.Fatal("expected call via filtered intermediary to be rejected") + } + if !isFilteredError(err) { + t.Fatalf("expected filtered error, got: %v", err) + } +} + +// TestAddressFilterContractDeploy verifies that deploying a contract from an EOA +// is rejected when the resulting contract address is filtered. +func TestAddressFilterContractDeploy(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false) + builder.isSequencer = true + cleanup := builder.Build(t) + defer cleanup() + + // Fund a deployer account + builder.L2Info.GenerateAccount("Deployer") + builder.L2.TransferBalance(t, "Owner", "Deployer", big.NewInt(1e18), builder.L2Info) + + // Compute the address that the next deployment from Deployer will create + deployerAddr := builder.L2Info.GetAddress("Deployer") + nonce, err := builder.L2.Client.NonceAt(ctx, deployerAddr, nil) + Require(t, err) + futureAddr := crypto.CreateAddress(deployerAddr, nonce) + + // Filter that future contract address + filter := newHashedChecker([]common.Address{futureAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(filter) + + // Deploy a contract (tx with no To address) - should be rejected + auth := builder.L2Info.GetDefaultTransactOpts("Deployer", ctx) + _, _, _, err = localgen.DeployAddressFilterTest(&auth, builder.L2.Client) + if err == nil { + t.Fatal("expected deployment to filtered address to be rejected") + } + if !isFilteredError(err) { + t.Fatalf("expected filtered error, got: %v", err) + } + + // Clear filter and verify deployment succeeds (nonce didn't increment, same address) + emptyChecker := newHashedChecker([]common.Address{}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(emptyChecker) + + auth = builder.L2Info.GetDefaultTransactOpts("Deployer", ctx) + _, tx, _, err := localgen.DeployAddressFilterTest(&auth, builder.L2.Client) + Require(t, err) + _, err = builder.L2.EnsureTxSucceeded(tx) + Require(t, err) +} + +func TestAddressFilterEventBypassRule(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + transferEvent := "Transfer(address,address,uint256)" + selector, _, err := eventfilter.CanonicalSelectorFromEvent(transferEvent) + Require(t, err) + + // Create rules with bypass: skip filtering when topic[1] (from) matches bypassAddr + bypassAddr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + rules := []eventfilter.EventRule{ + { + Event: transferEvent, + Selector: selector, + TopicAddresses: []int{1, 2}, + Bypass: &eventfilter.BypassRule{ + TopicIndex: 1, + Equals: bypassAddr, + }, + }, + } + + builder := NewNodeBuilder(ctx).DefaultConfig(t, false).WithEventFilterRules(rules) + builder.isSequencer = true + cleanup := builder.Build(t) + defer cleanup() + + // Deploy test contract + _, contract := deployAddressFilterTestContract(t, ctx, builder) + + // Create a filtered address + builder.L2Info.GenerateAccount("FilteredUser") + filteredAddr := builder.L2Info.GetAddress("FilteredUser") + + filter := newHashedChecker([]common.Address{filteredAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(filter) + + // Test 1: Transfer from random address to filtered address should be rejected + auth := builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + _, err = contract.EmitTransfer(&auth, auth.From, filteredAddr) + if err == nil { + t.Fatal("expected Transfer to filtered address to be rejected") + } + if !isFilteredError(err) { + t.Fatalf("expected filtered error, got: %v", err) + } + + // Test 2: Transfer FROM the bypass address TO the filtered address should succeed + // because the bypass rule skips filtering when from == bypassAddr + auth = builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + tx, err := contract.EmitTransfer(&auth, bypassAddr, filteredAddr) + Require(t, err) + _, err = builder.L2.EnsureTxSucceeded(tx) + Require(t, err) + + // Test 3: Transfer FROM filtered address (not bypass) should still be rejected + auth = builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + _, err = contract.EmitTransfer(&auth, filteredAddr, auth.From) + if err == nil { + t.Fatal("expected Transfer from filtered address to be rejected") + } + if !isFilteredError(err) { + t.Fatalf("expected filtered error, got: %v", err) + } +} + func TestAddressFilterWithFilteredEvents(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel()