Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions contracts-local/src/mocks/AddressFilterTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as i know, CALLCODE is considered deprecated, I'm not sure we need to support it

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't payTo be payable?

(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");
}
}
273 changes: 273 additions & 0 deletions system_tests/tx_address_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,279 @@ func TestAddressFilterSelfdestructOnConstruct(t *testing.T) {
Require(t, err)
}

func TestAddressFilterIndirectPayment(t *testing.T) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test seems very valuable, but it fails now. payTo should be payable — right now the intermediary reverts before the filtered address is ever reached. There could also be a deeper gap in the filter (value transfers to filtered EOAs via internal CALLs may not trigger TouchAddress), but probably worth merging latest master first to investigate

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seem to be similar to existing TestAddressFilterCall scenario

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()
Expand Down
Loading