diff --git a/arbos/block_processor.go b/arbos/block_processor.go index e283302ae9a..b33876ffb8a 100644 --- a/arbos/block_processor.go +++ b/arbos/block_processor.go @@ -10,6 +10,7 @@ import ( "math" "math/big" + "github.com/ethereum/go-ethereum/arbitrum/filter" "github.com/ethereum/go-ethereum/arbitrum_types" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core" @@ -42,13 +43,19 @@ var EmitTicketCreatedEvent func(*vm.EVM, [32]byte) error // ErrFilteredCascadingRedeem is returned via TxFailed when a redeem's // inner execution touches a filtered address, requiring the entire tx group -// (originating user tx + all its redeems) to be reverted. +// (originating user tx + all its redeems) to be reverted. All fields are +// captured before the group rollback so TxFailed can build a fully populated +// FilteredTxReport without late-filling. type ErrFilteredCascadingRedeem struct { - OriginatingTxHash common.Hash + OriginatingTx *types.Transaction + FilteredAddresses []filter.FilteredAddressRecord + BlockNumber uint64 + ParentBlockHash common.Hash + PositionInBlock int // receipt index of the originating user tx } func (e *ErrFilteredCascadingRedeem) Error() string { - return fmt.Sprintf("cascading redeem filtered (originating tx: %s)", e.OriginatingTxHash.Hex()) + return fmt.Sprintf("cascading redeem filtered (originating tx: %s)", e.OriginatingTx.Hash().Hex()) } // A helper struct that implements String() by marshalling to JSON. @@ -95,14 +102,14 @@ type groupCheckpoint struct { userTxsProcessed int completeLen int receiptsLen int - userTxHash common.Hash + userTx *types.Transaction } // saveGroupCheckpoint snapshots the loop state so the entire tx group can be // rolled back if a descendant redeem is filtered. header is passed separately // because only GasUsed is checkpointed; the rest of the header is immutable // during the loop. -func (s *blockBuildState) saveGroupCheckpoint(header *types.Header, snap int, userTxHash common.Hash) error { +func (s *blockBuildState) saveGroupCheckpoint(header *types.Header, snap int, userTx *types.Transaction) error { if len(s.redeems) != 0 { return errors.New("saveGroupCheckpoint called with pending redeems") } @@ -115,7 +122,7 @@ func (s *blockBuildState) saveGroupCheckpoint(header *types.Header, snap int, us userTxsProcessed: s.userTxsProcessed, completeLen: len(s.complete), receiptsLen: len(s.receipts), - userTxHash: userTxHash, + userTx: userTx, } return nil } @@ -213,10 +220,10 @@ type SequencingHooks interface { // SupportsGroupRollback returns whether the hooks support checkpointing and // rolling back a group of transactions (user tx + its scheduled redeems). SupportsGroupRollback() bool - // PreTxFilter rejects a tx before execution. - PreTxFilter(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *L1Info) error - // PostTxFilter rejects a tx after execution. - PostTxFilter(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult) error + // PreTxFilter rejects a tx before execution. positionInBlock is len(receipts) at call time. + PreTxFilter(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *L1Info, int) error + // PostTxFilter rejects a tx after execution. positionInBlock is len(receipts) at call time. + PostTxFilter(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult, int) error // BlockFilter rejects an entire block after all txs have been applied. BlockFilter(*types.Header, *state.StateDB, types.Transactions, types.Receipts) error // TxSucceeded records that the last user tx from NextTxToSequence executed successfully. @@ -244,11 +251,11 @@ func (n *NoopSequencingHooks) NextTxToSequence() (*types.Transaction, *arbitrum_ func (n *NoopSequencingHooks) CanDiscardTx() bool { return false } -func (n *NoopSequencingHooks) PreTxFilter(config *params.ChainConfig, header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, options *arbitrum_types.ConditionalOptions, address common.Address, info *L1Info) error { +func (n *NoopSequencingHooks) PreTxFilter(config *params.ChainConfig, header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, options *arbitrum_types.ConditionalOptions, address common.Address, info *L1Info, positionInBlock int) error { return nil } -func (n *NoopSequencingHooks) PostTxFilter(header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, address common.Address, u uint64, result *core.ExecutionResult) error { +func (n *NoopSequencingHooks) PostTxFilter(header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, address common.Address, u uint64, result *core.ExecutionResult, positionInBlock int) error { return nil } @@ -426,7 +433,7 @@ func ProduceBlockAdvanced( // Writes to statedb object should be avoided to prevent invalid state from permeating as statedb snapshot is not taken if isUserTx { - if err = sequencingHooks.PreTxFilter(chainConfig, header, buildState.statedb, buildState.arbState, tx, options, sender, l1Info); err != nil { + if err = sequencingHooks.PreTxFilter(chainConfig, header, buildState.statedb, buildState.arbState, tx, options, sender, l1Info, len(buildState.receipts)); err != nil { return nil, nil, err } } @@ -489,7 +496,7 @@ func ProduceBlockAdvanced( &header.GasUsed, runCtx, func(result *core.ExecutionResult) error { - if err := sequencingHooks.PostTxFilter(header, buildState.statedb, buildState.arbState, tx, sender, dataGas, result); err != nil { + if err := sequencingHooks.PostTxFilter(header, buildState.statedb, buildState.arbState, tx, sender, dataGas, result, len(buildState.receipts)); err != nil { return err } // Additional post-transaction validity check @@ -497,7 +504,7 @@ func ProduceBlockAdvanced( return err } if isUserTx && len(result.ScheduledTxes) > 0 && sequencingHooks.SupportsGroupRollback() { - if err := buildState.saveGroupCheckpoint(header, snap, tx.Hash()); err != nil { + if err := buildState.saveGroupCheckpoint(header, snap, tx); err != nil { return err } } @@ -519,11 +526,19 @@ func ProduceBlockAdvanced( // active group checkpoint, roll back the entire group (user tx + all // redeems) to the pre-group state. if !isUserTx && buildState.activeGroupCP != nil && errors.Is(err, state.ErrArbTxFilter) { - userTxHash := buildState.activeGroupCP.userTxHash + // Capture everything before rollback — addressCheckerStateß + cp := buildState.activeGroupCP + _, filteredAddresses := buildState.statedb.IsAddressFiltered() if err := buildState.rollbackToGroupCheckpoint(header); err != nil { return nil, nil, nil, err } - sequencingHooks.TxFailed(&ErrFilteredCascadingRedeem{OriginatingTxHash: userTxHash}) + sequencingHooks.TxFailed(&ErrFilteredCascadingRedeem{ + OriginatingTx: cp.userTx, + FilteredAddresses: filteredAddresses, + BlockNumber: header.Number.Uint64(), + ParentBlockHash: header.ParentHash, + PositionInBlock: cp.receiptsLen, + }) continue } if isUserTx { diff --git a/changelog/mnasr-nit-4644.md b/changelog/mnasr-nit-4644.md new file mode 100644 index 00000000000..ca13e3d0577 --- /dev/null +++ b/changelog/mnasr-nit-4644.md @@ -0,0 +1,2 @@ +### Added +- Report filtered delayed transactions to filtering-report service with structured FilteredTxReport diff --git a/execution/gethexec/executionengine.go b/execution/gethexec/executionengine.go index 8b234704d10..6d092017bfe 100644 --- a/execution/gethexec/executionengine.go +++ b/execution/gethexec/executionengine.go @@ -50,6 +50,7 @@ import ( "github.com/offchainlabs/nitro/arbutil" "github.com/offchainlabs/nitro/consensus" "github.com/offchainlabs/nitro/execution" + "github.com/offchainlabs/nitro/execution/gethexec/addressfilter" "github.com/offchainlabs/nitro/execution/gethexec/eventfilter" "github.com/offchainlabs/nitro/util/arbmath" "github.com/offchainlabs/nitro/util/containers" @@ -94,19 +95,22 @@ func (e *ErrFilteredDelayedMessage) Error() string { var ErrDelayedTxFiltered = errors.New("delayed transaction filtered") // DelayedFilteringSequencingHooks extends NoopSequencingHooks with address filtering -// for delayed message processing. Collects all tx hashes that touch filtered addresses -// and are not in the onchain filter. After block production, the caller checks if any -// hashes were collected and returns ErrFilteredDelayedMessage if so. +// for delayed message processing. Builds FilteredTxReport entries for txs that touch +// filtered addresses and are not in the onchain filter. After block production, the +// caller checks pendingFilteredTxReports and returns ErrFilteredDelayedMessage if any. type DelayedFilteringSequencingHooks struct { arbos.NoopSequencingHooks - FilteredTxHashes []common.Hash - eventFilter *eventfilter.EventFilter + filteredTxHashes []common.Hash + pendingFilteredTxReports []addressfilter.FilteredTxReport + eventFilter *eventfilter.EventFilter + inboxRequestId common.Hash } -func NewDelayedFilteringSequencingHooks(txes types.Transactions, ef *eventfilter.EventFilter) *DelayedFilteringSequencingHooks { +func NewDelayedFilteringSequencingHooks(txes types.Transactions, ef *eventfilter.EventFilter, inboxRequestId common.Hash) *DelayedFilteringSequencingHooks { return &DelayedFilteringSequencingHooks{ NoopSequencingHooks: *arbos.NewNoopSequencingHooks(txes), eventFilter: ef, + inboxRequestId: inboxRequestId, } } @@ -127,18 +131,20 @@ func touchAddresses(db *state.StateDB, tx *types.Transaction, sender common.Addr } // PostTxFilter touches To/From addresses and checks IsAddressFiltered. -// Collects tx hashes that touch filtered addresses but are not in the onchain filter. -// For redeems, returns ErrArbTxFilter to trigger group rollback. -func (f *DelayedFilteringSequencingHooks) PostTxFilter(header *types.Header, db *state.StateDB, a *arbosState.ArbosState, tx *types.Transaction, sender common.Address, dataGas uint64, result *core.ExecutionResult) error { +// Builds a FilteredTxReport and returns ErrArbTxFilter for filtered txs. +// For redeems, returns ErrArbTxFilter without a report (originating tx is +// collected in TxFailed after group rollback). +func (f *DelayedFilteringSequencingHooks) PostTxFilter(header *types.Header, db *state.StateDB, a *arbosState.ArbosState, tx *types.Transaction, sender common.Address, dataGas uint64, result *core.ExecutionResult, positionInBlock int) error { if tx.Type() == types.ArbitrumInternalTxType { return nil } touchAddresses(db, tx, sender) applyEventFilter(f.eventFilter, db) - if filtered, _ := db.IsAddressFiltered(); filtered { + if filtered, filteredAddresses := db.IsAddressFiltered(); filtered { // For redeems, return the filter error so the block processor can - // trigger a group rollback. + // trigger a group rollback. The block processor captures all report + // data before rollback and passes it through ErrFilteredCascadingRedeem. if tx.Type() == types.ArbitrumRetryTxType { return state.ErrArbTxFilter } @@ -148,24 +154,63 @@ func (f *DelayedFilteringSequencingHooks) PostTxFilter(header *types.Header, db if errors.As(result.Err, &filteredErr) { return nil } - // Otherwise, this tx touched a filtered address but wasn't in the - // onchain filter - collect it so the caller can halt. - f.FilteredTxHashes = append(f.FilteredTxHashes, tx.Hash()) + f.filteredTxHashes = append(f.filteredTxHashes, tx.Hash()) + + txRLP, err := tx.MarshalBinary() + if err != nil { + log.Error("error marshalling filtered delayed tx to RLP", "txHash", tx.Hash(), "err", err) + } else { + report := addressfilter.FilteredTxReport{ + ID: uuid.Must(uuid.NewV7()).String(), + TxHash: tx.Hash(), + TxRLP: txRLP, + FilteredAddresses: filteredAddresses, + BlockNumber: header.Number.Uint64(), + ParentBlockHash: header.ParentHash, + PositionInBlock: uint64(positionInBlock), // #nosec G115 + FilteredAt: time.Now().UTC(), + IsDelayed: true, + DelayedReportData: &addressfilter.DelayedReportData{InboxRequestId: f.inboxRequestId}, + } + f.pendingFilteredTxReports = append(f.pendingFilteredTxReports, report) + } + } return nil } func (f *DelayedFilteringSequencingHooks) SupportsGroupRollback() bool { return true } -// TxFailed extracts the originating tx hash from ErrFilteredCascadingRedeem -// and appends it to FilteredTxHashes. After ProduceBlockAdvanced returns, the -// existing check fires ErrFilteredDelayedMessage, causing the delayed sequencer -// to halt and the transaction-filterer to add the hash to the onchain filter. +// TxFailed builds a fully populated FilteredTxReport from +// ErrFilteredCascadingRedeem. The block processor captures all needed data +// (originating tx, filtered addresses, block metadata, user tx position) +// before the group rollback and passes it through the error. func (f *DelayedFilteringSequencingHooks) TxFailed(err error) { var cascadingErr *arbos.ErrFilteredCascadingRedeem - if errors.As(err, &cascadingErr) { - f.FilteredTxHashes = append(f.FilteredTxHashes, cascadingErr.OriginatingTxHash) + if !errors.As(err, &cascadingErr) { + return + } + originatingTxHash := cascadingErr.OriginatingTx.Hash() + f.filteredTxHashes = append(f.filteredTxHashes, originatingTxHash) + + txRLP, marshalErr := cascadingErr.OriginatingTx.MarshalBinary() + if marshalErr != nil { + log.Error("error marshalling originating tx RLP", "txHash", originatingTxHash, "err", marshalErr) + return } + report := addressfilter.FilteredTxReport{ + ID: uuid.Must(uuid.NewV7()).String(), + TxHash: originatingTxHash, + TxRLP: txRLP, + FilteredAddresses: cascadingErr.FilteredAddresses, + BlockNumber: cascadingErr.BlockNumber, + ParentBlockHash: cascadingErr.ParentBlockHash, + PositionInBlock: uint64(cascadingErr.PositionInBlock), // #nosec G115 + FilteredAt: time.Now().UTC(), + IsDelayed: true, + DelayedReportData: &addressfilter.DelayedReportData{InboxRequestId: f.inboxRequestId}, + } + f.pendingFilteredTxReports = append(f.pendingFilteredTxReports, report) } func applyEventFilter(ef *eventfilter.EventFilter, db *state.StateDB) { @@ -934,7 +979,11 @@ func (s *ExecutionEngine) createBlockFromNextMessage(msg *arbostypes.MessageWith log.Warn("error parsing incoming message for filtering", "err", err) txes = types.Transactions{} } - filteringHooks := NewDelayedFilteringSequencingHooks(txes, s.eventFilter) + var inboxRequestId common.Hash + if msg.Message.Header.RequestId != nil { + inboxRequestId = *msg.Message.Header.RequestId + } + filteringHooks := NewDelayedFilteringSequencingHooks(txes, s.eventFilter, inboxRequestId) block, statedb, receipts, err := arbos.ProduceBlockAdvanced( msg.Message.Header, @@ -951,10 +1000,11 @@ func (s *ExecutionEngine) createBlockFromNextMessage(msg *arbostypes.MessageWith return nil, nil, nil, err } // Check if any txs touched filtered addresses but are not in the onchain filter - if len(filteringHooks.FilteredTxHashes) > 0 { + if len(filteringHooks.filteredTxHashes) > 0 { if s.transactionFiltererRPCClient != nil { + filteredTxHashes := filteringHooks.filteredTxHashes s.LaunchThread(func(ctx context.Context) { - for _, filteredTxHash := range filteringHooks.FilteredTxHashes { + for _, filteredTxHash := range filteredTxHashes { _, err := s.transactionFiltererRPCClient.Filter(filteredTxHash).Await(ctx) if err != nil { log.Error("error reporting filtered tx to transaction-filterer", "filteredTxHash", filteredTxHash, "err", err) @@ -963,8 +1013,18 @@ func (s *ExecutionEngine) createBlockFromNextMessage(msg *arbostypes.MessageWith }) } + // Report structured reports to filtering-report service (non-blocking) + if s.filteringReportRPCClient != nil && len(filteringHooks.pendingFilteredTxReports) > 0 { + reports := filteringHooks.pendingFilteredTxReports + s.LaunchThread(func(ctx context.Context) { + if _, err := s.filteringReportRPCClient.ReportFilteredTransactions(reports).Await(ctx); err != nil { + log.Error("error reporting filtered delayed txs to filtering-report", "count", len(reports), "err", err) + } + }) + } + return nil, nil, nil, &ErrFilteredDelayedMessage{ - TxHashes: filteringHooks.FilteredTxHashes, + TxHashes: filteringHooks.filteredTxHashes, DelayedMsgIdx: msg.DelayedMessagesRead - 1, } } diff --git a/execution/gethexec/sequencer.go b/execution/gethexec/sequencer.go index 86cbba04c64..45478bb66c4 100644 --- a/execution/gethexec/sequencer.go +++ b/execution/gethexec/sequencer.go @@ -703,7 +703,7 @@ func (s *Sequencer) publishTransactionToQueue(queueCtx context.Context, tx *type return nil } -func (s *Sequencer) preTxFilter(_ *params.ChainConfig, header *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, options *arbitrum_types.ConditionalOptions, sender common.Address, l1Info *arbos.L1Info) error { +func (s *Sequencer) preTxFilter(_ *params.ChainConfig, header *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, options *arbitrum_types.ConditionalOptions, sender common.Address, l1Info *arbos.L1Info, _ int) error { if s.nonceCache.Caching() { stateNonce := s.nonceCache.Get(header, statedb, sender) err := MakeNonceError(sender, tx.Nonce(), stateNonce) @@ -729,7 +729,7 @@ func (s *Sequencer) preTxFilter(_ *params.ChainConfig, header *types.Header, sta return nil } -func (s *Sequencer) postTxFilter(header *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, sender common.Address, dataGas uint64, result *core.ExecutionResult) error { +func (s *Sequencer) postTxFilter(header *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, sender common.Address, dataGas uint64, result *core.ExecutionResult, _ int) error { if s.eventFilter != nil { logs := statedb.GetCurrentTxLogs() for _, l := range logs { @@ -921,8 +921,8 @@ type FullSequencingHooks struct { sequencedTxsSizeSoFar int maxSequencedTxsSize int txErrors []error - preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info) error - postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult) error + preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info, int) error + postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult, int) error blockFilter func(*types.Header, *state.StateDB, types.Transactions, types.Receipts) error txSizeLimitReached bool } @@ -1024,19 +1024,19 @@ func (s *FullSequencingHooks) SequencedTx(txId int) (*types.Transaction, error) return s.queueItems[txId].tx, nil } -func (s *FullSequencingHooks) PreTxFilter(config *params.ChainConfig, header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, options *arbitrum_types.ConditionalOptions, address common.Address, info *arbos.L1Info) error { +func (s *FullSequencingHooks) PreTxFilter(config *params.ChainConfig, header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, options *arbitrum_types.ConditionalOptions, address common.Address, info *arbos.L1Info, positionInBlock int) error { if s.preTxFilter != nil { - return s.preTxFilter(config, header, db, a, transaction, options, address, info) + return s.preTxFilter(config, header, db, a, transaction, options, address, info, positionInBlock) } return nil } -func (s *FullSequencingHooks) PostTxFilter(header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, address common.Address, u uint64, result *core.ExecutionResult) error { +func (s *FullSequencingHooks) PostTxFilter(header *types.Header, db *state.StateDB, a *arbosState.ArbosState, transaction *types.Transaction, address common.Address, u uint64, result *core.ExecutionResult, positionInBlock int) error { if transaction.Type() == types.ArbitrumInternalTxType { return nil } if s.postTxFilter != nil { - return s.postTxFilter(header, db, a, transaction, address, u, result) + return s.postTxFilter(header, db, a, transaction, address, u, result, positionInBlock) } return nil } @@ -1051,8 +1051,8 @@ func (s *FullSequencingHooks) BlockFilter(header *types.Header, db *state.StateD func MakeSequencingHooks( items []txQueueItem, maxSequencedTxsSize int, - preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info) error, - postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult) error, + preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info, int) error, + postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult, int) error, blockFilter func(*types.Header, *state.StateDB, types.Transactions, types.Receipts) error, ) *FullSequencingHooks { res := &FullSequencingHooks{ @@ -1071,8 +1071,8 @@ func MakeSequencingHooks( // This allows all transactions to be included in a block regardless of size. func MakeZeroTxSizeSequencingHooksForTesting( txes types.Transactions, - preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info) error, - postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult) error, + preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info, int) error, + postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult, int) error, blockFilter func(*types.Header, *state.StateDB, types.Transactions, types.Receipts) error, ) *FullSequencingHooks { var items []txQueueItem diff --git a/system_tests/delayed_message_filter_test.go b/system_tests/delayed_message_filter_test.go index eaf1122f791..e080afff726 100644 --- a/system_tests/delayed_message_filter_test.go +++ b/system_tests/delayed_message_filter_test.go @@ -6,16 +6,22 @@ package arbtest import ( "context" "math/big" + "slices" + "sync" "testing" "time" "github.com/stretchr/testify/require" "github.com/ethereum/go-ethereum/accounts/abi/bind" + filterTypes "github.com/ethereum/go-ethereum/arbitrum/filter" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" "github.com/offchainlabs/nitro/arbnode" "github.com/offchainlabs/nitro/arbos" @@ -25,6 +31,8 @@ import ( arbosutil "github.com/offchainlabs/nitro/arbos/util" "github.com/offchainlabs/nitro/cmd/chaininfo" "github.com/offchainlabs/nitro/cmd/transaction-filterer/api" + "github.com/offchainlabs/nitro/execution/gethexec" + "github.com/offchainlabs/nitro/execution/gethexec/addressfilter" "github.com/offchainlabs/nitro/execution/gethexec/eventfilter" "github.com/offchainlabs/nitro/solgen/go/bridgegen" "github.com/offchainlabs/nitro/solgen/go/localgen" @@ -153,6 +161,53 @@ func createTransactionFiltererService(t *testing.T, ctx context.Context, builder return transactionFiltererAPI } +// mockFilteringReportAPI is a test mock that records reports received via the +// filteringreport_reportFilteredTransactions RPC endpoint. +type mockFilteringReportAPI struct { + mu sync.Mutex + receivedReports []addressfilter.FilteredTxReport +} + +func (m *mockFilteringReportAPI) ReportFilteredTransactions(_ context.Context, reports []addressfilter.FilteredTxReport) error { + m.mu.Lock() + defer m.mu.Unlock() + m.receivedReports = append(m.receivedReports, reports...) + return nil +} + +func (m *mockFilteringReportAPI) ReceivedReports() []addressfilter.FilteredTxReport { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.receivedReports) +} + +// createFilteringReportService starts a local mock filtering-report RPC server +// and configures the builder to send reports to it. Must be called BEFORE builder.Build. +func createFilteringReportService(t *testing.T, builder *NodeBuilder) *mockFilteringReportAPI { + t.Helper() + mockAPI := &mockFilteringReportAPI{} + stack, err := node.New(&node.Config{ + DataDir: "", + HTTPHost: "127.0.0.1", + HTTPPort: 0, + WSPort: 0, + AuthPort: 0, + HTTPModules: []string{gethexec.FilteringReportNamespace}, + P2P: p2p.Config{ListenAddr: "", NoDiscovery: true, NoDial: true}, + }) + require.NoError(t, err) + stack.RegisterAPIs([]rpc.API{{ + Namespace: gethexec.FilteringReportNamespace, + Version: "1.0", + Service: mockAPI, + Public: true, + }}) + require.NoError(t, stack.Start()) + t.Cleanup(func() { stack.Close() }) + builder.execConfig.TransactionFiltering.FilteringReportRPCClient.URL = stack.HTTPEndpoint() + return mockAPI +} + // addTxHashToOnChainFilter adds a tx hash to the onchain filter via the precompile. func addTxHashToOnChainFilter(t *testing.T, ctx context.Context, builder *NodeBuilder, txHash common.Hash, filtererName string) { t.Helper() @@ -241,6 +296,7 @@ func TestDelayedMessageFilterHalting(t *testing.T) { defer cancel() builder := setupFilteredTxTestBuilder(t, ctx) + reportAPI := createFilteringReportService(t, builder) cleanup := builder.Build(t) defer cleanup() @@ -274,6 +330,45 @@ func TestDelayedMessageFilterHalting(t *testing.T) { finalBalance, err := builder.L2.Client.BalanceAt(ctx, filteredAddr, nil) require.NoError(t, err) require.Equal(t, initialBalance, finalBalance, "filtered address balance should not change") + + // Verify filtering-report service received the report + require.Eventually(t, func() bool { + return len(reportAPI.ReceivedReports()) > 0 + }, 5*time.Second, 100*time.Millisecond, "filtering-report should receive reports") + + reports := reportAPI.ReceivedReports() + require.Len(t, reports, 1) + report := reports[0] + + // Core identity + require.Equal(t, txHash, report.TxHash) + require.NotEmpty(t, report.ID) + require.True(t, report.IsDelayed) + require.NotNil(t, report.DelayedReportData, "delayed report data should be set") + + // TxRLP should be populated + require.NotEmpty(t, report.TxRLP, "TxRLP should be populated") + + // Block metadata: block number non-zero, parent block hash matches block N-1 + require.NotZero(t, report.BlockNumber, "block number should be set") + parentBlock, err := builder.L2.Client.BlockByNumber(ctx, big.NewInt(int64(report.BlockNumber-1))) // #nosec G115 + require.NoError(t, err) + require.Equal(t, parentBlock.Hash(), report.ParentBlockHash, + "parent block hash should match hash of block N-1") + + // Filtered addresses: target address with reason "to" and no EventRuleMatch + require.NotEmpty(t, report.FilteredAddresses) + foundTo := false + for _, addr := range report.FilteredAddresses { + if addr.Address == filteredAddr && addr.Reason == filterTypes.ReasonTo { + require.Nil(t, addr.EventRuleMatch, + "direct address filter should not have EventRuleMatch") + foundTo = true + break + } + } + require.True(t, foundTo, + "report should contain filtered address with reason 'to'") } // TestDelayedMessageFilterBypass verifies that adding tx hash to onchain filter allows tx to proceed. @@ -2628,6 +2723,91 @@ func TestDelayedMessageFilterCatchesEventFilter(t *testing.T) { "filtered tx should have failed receipt status") } +// TestDelayedMessageFilterCatchesEventFilterReport verifies that the filtering +// report includes a non-nil EventRuleMatch when the delayed message is filtered +// via an event filter rule (not a direct address match). +func TestDelayedMessageFilterCatchesEventFilterReport(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + selector, _, err := eventfilter.CanonicalSelectorFromEvent("Transfer(address,address,uint256)") + require.NoError(t, err) + rules := []eventfilter.EventRule{{ + Event: "Transfer(address,address,uint256)", + Selector: selector, + TopicAddresses: []int{1, 2}, + }} + + arbOSInit := ¶ms.ArbOSInit{TransactionFilteringEnabled: true} + builder := NewNodeBuilder(ctx). + DefaultConfig(t, true). + WithArbOSVersion(params.ArbosVersion_60). + WithArbOSInit(arbOSInit). + WithEventFilterRules(rules) + builder.isSequencer = true + builder.nodeConfig.DelayedSequencer.Enable = true + builder.nodeConfig.DelayedSequencer.FinalizeDistance = 1 + + reportAPI := createFilteringReportService(t, builder) + cleanup := builder.Build(t) + defer cleanup() + + builder.L2Info.GenerateAccount("Sender") + builder.L2Info.GenerateAccount("FilteredTarget") + builder.L2.TransferBalance(t, "Owner", "Sender", big.NewInt(1e18), builder.L2Info) + + senderAddr := builder.L2Info.GetAddress("Sender") + filteredAddr := builder.L2Info.GetAddress("FilteredTarget") + contractAddr, _ := deployAddressFilterTestContractForDelayed(t, ctx, builder) + + addrFilter := newHashedChecker([]common.Address{filteredAddr}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, addrFilter) + + contractABI, err := localgen.AddressFilterTestMetaData.GetAbi() + require.NoError(t, err) + callData, err := contractABI.Pack("emitTransfer", senderAddr, filteredAddr) + require.NoError(t, err) + + delayedTx := prepareDelayedContractCall(t, builder, "Sender", contractAddr, callData) + txHash := sendDelayedTx(t, ctx, builder, delayedTx) + advanceL1ForDelayed(t, ctx, builder) + waitForDelayedSequencerHaltOnHashes(t, ctx, builder, []common.Hash{txHash}, 10*time.Second) + + // Verify report + require.Eventually(t, func() bool { + return len(reportAPI.ReceivedReports()) > 0 + }, 5*time.Second, 100*time.Millisecond, "filtering-report should receive reports") + + reports := reportAPI.ReceivedReports() + require.Len(t, reports, 1) + report := reports[0] + + require.Equal(t, txHash, report.TxHash) + require.True(t, report.IsDelayed) + require.NotNil(t, report.DelayedReportData) + require.NotEmpty(t, report.TxRLP) + require.NotZero(t, report.BlockNumber) + + parentBlock, err := builder.L2.Client.BlockByNumber(ctx, big.NewInt(int64(report.BlockNumber-1))) // #nosec G115 + require.NoError(t, err) + require.Equal(t, parentBlock.Hash(), report.ParentBlockHash, + "parent block hash should match hash of block N-1") + + // Find the event-rule-triggered filtered address + foundEventRule := false + for _, addr := range report.FilteredAddresses { + if addr.Address == filteredAddr && addr.Reason == filterTypes.ReasonEventRule { + require.NotNil(t, addr.EventRuleMatch, "event rule match should be populated") + require.Equal(t, "Transfer(address,address,uint256)", addr.EventRuleMatch.MatchedEvent) + require.NotNil(t, addr.EventRuleMatch.RawLog, "raw log should be populated") + foundEventRule = true + break + } + } + require.True(t, foundEventRule, + "report should contain filtered address with event_rule reason") +} + // TestFilteredArbitrumDepositTx verifies that an L1->L2 ETH deposit (ArbitrumDepositTx) // to a filtered address is handled correctly when the tx hash is in the onchain filter. // The deposit funds should be redirected to FilteredFundsRecipient (or networkFeeAccount diff --git a/system_tests/retryable_tickets_filtering_test.go b/system_tests/retryable_tickets_filtering_test.go index dea2019a627..ead9f325f19 100644 --- a/system_tests/retryable_tickets_filtering_test.go +++ b/system_tests/retryable_tickets_filtering_test.go @@ -9,12 +9,18 @@ import ( "github.com/stretchr/testify/require" "github.com/ethereum/go-ethereum/accounts/abi/bind" + filterTypes "github.com/ethereum/go-ethereum/arbitrum/filter" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/params" + "github.com/offchainlabs/nitro/arbnode" + "github.com/offchainlabs/nitro/arbos" + "github.com/offchainlabs/nitro/arbos/arbostypes" "github.com/offchainlabs/nitro/arbos/retryables" + "github.com/offchainlabs/nitro/cmd/chaininfo" "github.com/offchainlabs/nitro/execution/gethexec/eventfilter" + "github.com/offchainlabs/nitro/solgen/go/bridgegen" "github.com/offchainlabs/nitro/solgen/go/localgen" "github.com/offchainlabs/nitro/solgen/go/precompilesgen" ) @@ -984,9 +990,9 @@ func TestRetryableFilteringAutoRedeemCascadeWithCallValue(t *testing.T) { require.NoError(t, err) arbRetryableTxAddr := common.HexToAddress("6e") - callValue := big.NewInt(1e6) + callValue := big.NewInt(1e7) - // Submit B (gasLimit=0, callValue=1e6, data=callTarget(filteredTarget)). Process. + // Submit B (gasLimit=0, callValue=1e7, data=callTarget(filteredTarget)). Process. bRetryData, err := callerABI.Pack("callTarget", filteredTarget) require.NoError(t, err) _, ticketIdB := submitRetryableNoAutoRedeem( @@ -1655,3 +1661,132 @@ func TestRetryableFilteringFanoutL2ManualRedeemDirtyLast(t *testing.T) { // After clearing filter, manual redeem of A succeeds manualRedeemSucceeds(t, ctx, builder, ticketIdA) } + +// TestRetryableFilteringAutoRedeemFilteredDepth1Report verifies that the +// TxFailed code path (cascading redeem) produces a correct FilteredTxReport. +// A retryable's auto-redeem calls a filtered contract, triggering group +// rollback. The report should contain the originating submission tx hash, +// the filtered address, and valid block metadata. +func TestRetryableFilteringAutoRedeemFilteredDepth1Report(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Setup builder with report mock BEFORE Build + builder := setupFilteredTxTestBuilder(t, ctx) + reportAPI := createFilteringReportService(t, builder) + cleanup := builder.Build(t) + defer cleanup() + + // Create accounts and grant filterer role + builder.L2Info.GenerateAccount("Filterer") + builder.L2Info.GenerateAccount("FundsRecipient") + builder.L2Info.GenerateAccount("CleanBeneficiary") + builder.L2.TransferBalance(t, "Owner", "Filterer", big.NewInt(1e18), builder.L2Info) + + ownerTxOpts := builder.L2Info.GetDefaultTransactOpts("Owner", ctx) + arbOwner, err := precompilesgen.NewArbOwner(types.ArbOwnerAddress, builder.L2.Client) + require.NoError(t, err) + tx, err := arbOwner.AddTransactionFilterer(&ownerTxOpts, builder.L2Info.GetAddress("Filterer")) + require.NoError(t, err) + _, err = builder.L2.EnsureTxSucceeded(tx) + require.NoError(t, err) + + fundsRecipientAddr := builder.L2Info.GetAddress("FundsRecipient") + tx, err = arbOwner.SetFilteredFundsRecipient(&ownerTxOpts, fundsRecipientAddr) + require.NoError(t, err) + _, err = builder.L2.EnsureTxSucceeded(tx) + require.NoError(t, err) + + cleanBeneficiary := builder.L2Info.GetAddress("CleanBeneficiary") + + // Deploy caller and filtered target contracts + callerAddr, _ := deployAddressFilterTestContractForDelayed(t, ctx, builder) + filteredTarget, _ := deployAddressFilterTestContractForDelayed(t, ctx, builder) + + // Set address filter on filteredTarget + addrFilter := newHashedChecker([]common.Address{filteredTarget}) + builder.L2.ExecNode.ExecEngine.SetAddressChecker(t, addrFilter) + + // Build retryableFilterTestParams for submitRetryableViaL1 + delayedInbox, err := bridgegen.NewInbox(builder.L1Info.GetAddress("Inbox"), builder.L1.Client) + require.NoError(t, err) + delayedBridge, err := arbnode.NewDelayedBridge(builder.L1.Client, builder.L1Info.GetAddress("Bridge"), 0) + require.NoError(t, err) + + lookupL2Tx := func(l1Receipt *types.Receipt) *types.Transaction { + messages, err := delayedBridge.LookupMessagesInRange(ctx, l1Receipt.BlockNumber, l1Receipt.BlockNumber, nil) + require.NoError(t, err) + require.NotEmpty(t, messages) + for _, message := range messages { + if message.Message.Header.Kind != arbostypes.L1MessageType_SubmitRetryable { + continue + } + txs, err := arbos.ParseL2Transactions(message.Message, chaininfo.ArbitrumDevTestChainConfig().ChainID, params.MaxDebugArbosVersionSupported) + require.NoError(t, err) + for _, parsedTx := range txs { + if parsedTx.Type() == types.ArbitrumSubmitRetryableTxType { + return parsedTx + } + } + } + t.Fatal("no retryable submission tx found") + return nil + } + + p := &retryableFilterTestParams{ + builder: builder, + ctx: ctx, + delayedInbox: delayedInbox, + lookupL2Tx: lookupL2Tx, + filtererName: "Filterer", + fundsRecipientAddr: fundsRecipientAddr, + } + + // Submit retryable A: auto-redeem calls callTarget(filteredTarget) + callerABI, err := localgen.AddressFilterTestMetaData.GetAbi() + require.NoError(t, err) + retryData, err := callerABI.Pack("callTarget", filteredTarget) + require.NoError(t, err) + + _, ticketId := submitRetryableViaL1(t, p, "Faucet", callerAddr, common.Big0, cleanBeneficiary, cleanBeneficiary, retryData) + advanceL1ForDelayed(t, ctx, builder) + + // A's auto-redeem calls callTarget(filteredTarget) → filter → group revert → halt + waitForDelayedSequencerHaltOnHashes(t, ctx, builder, []common.Hash{ticketId}, 10*time.Second) + + // Verify report from the TxFailed/cascading redeem path + require.Eventually(t, func() bool { + return len(reportAPI.ReceivedReports()) > 0 + }, 5*time.Second, 100*time.Millisecond, "filtering-report should receive cascading redeem report") + + reports := reportAPI.ReceivedReports() + require.Len(t, reports, 1) + report := reports[0] + + // Core identity: should be the originating submission tx, not the redeem + require.Equal(t, ticketId, report.TxHash) + require.NotEmpty(t, report.ID) + require.True(t, report.IsDelayed) + require.NotNil(t, report.DelayedReportData, "delayed report data should be set") + require.NotEmpty(t, report.TxRLP, "TxRLP should be populated") + + // Block metadata + require.NotZero(t, report.BlockNumber, "block number should be set") + parentBlock, err := builder.L2.Client.BlockByNumber(ctx, big.NewInt(int64(report.BlockNumber-1))) + require.NoError(t, err) + require.Equal(t, parentBlock.Hash(), report.ParentBlockHash, + "parent block hash should match hash of block N-1") + + // Filtered addresses: should contain filteredTarget (the contract the redeem called) + require.NotEmpty(t, report.FilteredAddresses) + foundTarget := false + for _, addr := range report.FilteredAddresses { + if addr.Address == filteredTarget { + require.Equal(t, filterTypes.ReasonContractAddress, addr.Reason, + "filtered target should be caught as contract_address") + foundTarget = true + break + } + } + require.True(t, foundTarget, "report should contain the filtered target address") +} diff --git a/system_tests/seq_filter_test.go b/system_tests/seq_filter_test.go index ae80de0616d..1188f6c908c 100644 --- a/system_tests/seq_filter_test.go +++ b/system_tests/seq_filter_test.go @@ -113,8 +113,8 @@ func setupSequencerFilterTest(t *testing.T, isBlockFilter bool) (*NodeBuilder, * txes = append(txes, builder.L2Info.PrepareTx("Owner", "User", builder.L2Info.TransferGas, big.NewInt(1e12), []byte{1, 2, 3})) txes = append(txes, builder.L2Info.PrepareTx("User", "Owner", builder.L2Info.TransferGas, big.NewInt(1e12), nil)) - var preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info) error - var postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult) error + var preTxFilter func(*params.ChainConfig, *types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, *arbitrum_types.ConditionalOptions, common.Address, *arbos.L1Info, int) error + var postTxFilter func(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult, int) error var blockFilter func(*types.Header, *state.StateDB, types.Transactions, types.Receipts) error if isBlockFilter { @@ -125,13 +125,13 @@ func setupSequencerFilterTest(t *testing.T, isBlockFilter bool) (*NodeBuilder, * return nil } } else { - preTxFilter = func(_ *params.ChainConfig, _ *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, _ *arbitrum_types.ConditionalOptions, _ common.Address, _ *arbos.L1Info) error { + preTxFilter = func(_ *params.ChainConfig, _ *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, _ *arbitrum_types.ConditionalOptions, _ common.Address, _ *arbos.L1Info, _ int) error { if len(tx.Data()) > 0 { statedb.FilterTx() } return nil } - postTxFilter = func(_ *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, _ common.Address, _ uint64, _ *core.ExecutionResult) error { + postTxFilter = func(_ *types.Header, statedb *state.StateDB, _ *arbosState.ArbosState, tx *types.Transaction, _ common.Address, _ uint64, _ *core.ExecutionResult, _ int) error { if statedb.IsTxFiltered() { return state.ErrArbTxFilter }