Skip to content

Comments

Address filtering for redeems#4352

Open
Tristan-Wilson wants to merge 24 commits intomasterfrom
filter-submit-retryable
Open

Address filtering for redeems#4352
Tristan-Wilson wants to merge 24 commits intomasterfrom
filter-submit-retryable

Conversation

@Tristan-Wilson
Copy link
Member

@Tristan-Wilson Tristan-Wilson commented Feb 9, 2026

Overview

This PR extends the address filtering system to handle retryable transactions
end-to-end. The existing filtering infrastructure covers regular L2 transactions
(via PostTxFilter and IsAddressFiltered), but retryables introduce several
gaps where funds or execution can reach filtered addresses unchecked.

There are four distinct challenges, each with its own solution:

  1. Filtered retryable submissions -- the retryable's outer fields
    (Beneficiary, FeeRefundAddr, RetryTo) may name filtered addresses. Solution:
    redirect funds to filteredFundsRecipient, still create the ticket, skip
    auto-redeem.

  2. Cascading redeems that touch filtered addresses -- the retryable's inner
    execution (auto-redeem or manual redeem) may CALL, CREATE, SELFDESTRUCT to,
    or emit events involving filtered addresses. This is the hardest problem
    because redeems are generated inside the STF and can't simply be dropped
    without causing consensus divergence. Solution: checkpoint-and-revert the
    entire transaction group.

  3. Event filter wiring for delayed messages -- the event filter (log-based
    address detection for Transfer, TransferSingle, TransferBatch) was only wired
    into the sequencer's postTxFilter, not into the delayed message path.
    Solution: plumb the event filter through to DelayedFilteringSequencingHooks.

  4. Delayed manual redeem filtering -- a signed L2 tx sent via the delayed
    inbox that calls ArbRetryableTx.redeem() needs the same protection as
    auto-redeems. Handled by the same group revert mechanism via
    DelayedFilteringSequencingHooks.RedeemFilter and ReportGroupRevert.


1. Filtered retryable submissions

Problem

PostTxFilter touches sender and tx.To() but not the retryable-specific
fields (Beneficiary, FeeRefundAddr, RetryTo). When the onchain filter
contains the tx hash, StartTxHook had no handling for the retryable case, so
funds would flow to filtered addresses.

Retryable submissions are L1 delayed messages -- they are force-included from L1
and must be processed. The user's ETH is already locked in the L1 bridge;
processing always mints it on L2 via MintBalance in StartTxHook. Rejecting
the submission would leave funds stuck in escrow with an unreachable beneficiary.

Solution

In StartTxHook for ArbitrumSubmitRetryableTx, check
IsFilteredFree(ticketId). When the tx hash is in the onchain filter:

  1. Beneficiary and FeeRefundAddr are redirected to
    filteredFundsRecipient (a new ArbOS state field with ArbOwner precompile
    accessors). Falls back to networkFeeAccount if no recipient is configured.
  2. The retryable ticket is still created -- with the redirected beneficiary.
  3. Auto-redeem scheduling is skipped entirely (the RetryData calldata may
    target filtered addresses).
  4. ErrFilteredTx is set as result.Err so that PostTxFilter can detect the
    tx was already handled by the onchain filter and skip re-halting the delayed
    sequencer.

The redirected beneficiary can later manually redeem if the inner execution is
clean, or the retryable expires and funds go to the redirected beneficiary.

A new touchRetryableAddresses() helper touches Beneficiary, FeeRefundAddr,
RetryTo, and their de-aliased versions (InverseRemapL1Address) in
PostTxFilter. This ensures the address filter detects retryable-specific
fields during the initial tentative pass (before the tx hash is in the onchain
filter).

Design decisions

  • Redirect instead of reject. L1 delayed messages cannot be dropped -- the
    user's ETH is locked in the L1 bridge and will be minted on L2 when processed.
    Redirecting Beneficiary/FeeRefundAddr keeps the
    retryable alive with funds flowing to a safe recipient.

  • Skip auto-redeem for filtered retryables. The RetryData calldata may
    target filtered addresses. Skipping the auto-redeem prevents the inner
    execution from touching those addresses. The redirected beneficiary can
    manually redeem if the inner execution is clean.

  • ErrFilteredTx marker in result.Err. Without this marker, PostTxFilter
    sees the original (still-filtered) Beneficiary via touchRetryableAddresses
    and re-halts the delayed sequencer. The error signals that the onchain filter
    already handled this tx.

  • De-aliased address touching. The L1 Inbox aliases contract addresses for
    Beneficiary and FeeRefundAddr. We touch both the aliased and original
    (InverseRemapL1Address) versions so filtering catches the L1 address.

  • GetInner() deep copy. The tx.Beneficiary mutation does not affect the tx
    hash used elsewhere because GetInner() returns a deep copy.

Implementation

  • arbos/tx_processor.go: StartTxHook for ArbitrumSubmitRetryableTx
    checks IsFilteredFree on the ticketId. If filtered, redirects
    FeeRefundAddr and Beneficiary to filteredFundsRecipient, sets
    ErrFilteredTx as result error, and skips auto-redeem scheduling. Removed
    unnecessary ArbOSVersion >= ArbosVersion_TransactionFiltering check from the
    IsFilteredFree call (matches RevertedTxHook pattern).

  • execution/gethexec/executionengine.go: Added touchRetryableAddresses to
    PostTxFilter: touches Beneficiary, FeeRefundAddr, RetryTo and their
    de-aliased versions.


2. Cascading redeem checkpoint-and-revert

Problem

When a retryable is redeemed (auto or manual), the ArbitrumRetryTx runs with
hooks = nil in the block processor, so PostTxFilter never fires. The EVM
execution can touch filtered addresses via CALL, CREATE, SELFDESTRUCT, or
STATICCALL, but nobody checks IsAddressFiltered() afterwards.

Simply dropping the redeem is not safe. The retryable's CreateRetryable runs
in StartTxHook, and the auto-redeem is scheduled by StartTxHook (via the
RedeemScheduled event) and executed as a follow-on transaction in the same
block. If we drop only the redeem at the sequencing level, the validator (which
doesn't run sequencing hooks) still executes it. The validator sees different state -- different receipts, different
gas accounting, different retryable numTries -- causing a consensus
divergence.

Solution

The block processor takes a statedb snapshot before each user transaction and
processes the entire group (user tx + all its auto-redeems) tentatively with
skipFinalise. A new RedeemFilter method on the SequencingHooks interface
is called after each redeem executes -- it runs the event filter on logs and
checks IsAddressFiltered(). If any redeem touches a filtered address,
RedeemFilter returns ErrArbTxFilter, and the entire group is reverted to the
snapshot.

The originating tx hash is reported to the sequencing hooks via
ReportGroupRevert, which halts the delayed sequencer (for delayed messages) or
returns an error to the RPC caller (for sequencer txs). The operator then adds
the tx hash to the onchain filter, and the submission re-processes through the
filtered retryable redirect path (section 1 above).

Design decisions

  • Revert the entire group, not just the redeem. The redeem executes as a
    follow-on transaction in the same block, generated inside
    ProduceBlockAdvanced via ScheduledTxes(). Dropping only the redeem would
    diverge from the validator path where the redeem always runs. Reverting the
    full group (user tx + all redeems) and re-processing with the onchain filter
    ensures both paths produce identical state.

  • skipFinalise is needed. StateDB.Finalise() promotes dirtyStorage to
    pendingStorage (not journaled), clears the journal, and zeros the refund
    counter. After Finalise, RevertToSnapshot cannot undo past that boundary.
    We skip Finalise while a group checkpoint is active so the entire group can
    be cleanly reverted. Finalise is flushed at group boundaries (before the
    next user tx or at end of block).

  • SubRefund is needed for consensus correctness. Without Finalise between
    txs in a tentative group, the EVM refund counter leaks across tx boundaries.
    calcRefund() reads statedb.GetRefund() (cumulative counter), and
    Prepare() does NOT reset it. A leaked refund would cause gasUsed values
    that differ from what per-tx Finalise would produce (the canonical gas
    accounting behavior). SubRefund drains the counter to zero before each tx,
    mimicking what Finalise would do. It's journaled, so group revert undoes it.

  • RedeemFilter via sequencingHooks not hooks. hooks is intentionally nil
    for redeems -- it gates sequencer policies (PreTxFilter nonce checking,
    PostTxFilter nonce cache updates/revert gas rejection, InsertLastTxError,
    DiscardInvalidTxsEarly) that don't apply to protocol-scheduled transactions.
    RedeemFilter is called on sequencingHooks (the function parameter, always
    non-nil) directly to get only the narrow redeem filtering behavior.

  • Dropping redeems is safe. State reverts via RevertToSnapshot. The
    retryable ticket survives (DeleteRetryable only runs on successful redeem in
    EndTxHook). Ticket can be manually redeemed later or expires to beneficiary.
    This is a sequencing-level decision -- NoopSequencingHooks.RedeemFilter
    returns nil during replay/validation.

  • Checkpoints are unconditional. Every user tx gets a checkpoint regardless
    of whether filtering is active. statedb.Snapshot() is a lightweight
    operation (appends to the journal's revision list). Avoiding a
    SupportsGroupRevert check simplifies the code and eliminates a class of
    bugs where the optimization flag gets out of sync.

  • Minimal interface additions. Only two new methods on SequencingHooks:
    RedeemFilter and ReportGroupRevert. NoopSequencingHooks provides no-op
    defaults so non-filtering paths pay no cost.

Implementation

  • go-ethereum (submodule bump): Added skipFinalise bool parameter to
    ApplyTransactionWithEVM and ApplyTransactionWithResultFilter. Existing
    callers (Process, ApplyTransaction, etc.) pass false explicitly. When
    true, skips Finalise() after a committed tx, keeping the journal and
    dirtyStorage intact for cross-tx snapshot revert.

  • arbos/block_processor.go: Added ErrFilteredCascadingRedeem error type
    carrying OriginatingTxHash. Added RedeemFilter and ReportGroupRevert to
    SequencingHooks interface, with no-op defaults on NoopSequencingHooks.
    Added groupCheckpoint struct (snapshot ID, header gas, block gas, expected
    balance delta, complete/receipts lengths, userTxsProcessed, gethGas pool,
    userTxHash) with lint:require-exhaustive-initialization. Added
    revertToGroupCheckpoint closure: reverts statedb, resets non-statedb values,
    clears tx filter, reopens ArbOS state, calls ReportGroupRevert. Checkpoint
    taken before each user tx, skipFinalise passed when group is active,
    Finalise flushed at group boundaries. SubRefund drains refund counter
    before each tx in a tentative group. On ErrArbTxFilter from a redeem, calls
    revertToGroupCheckpoint and continues the loop. If the user tx itself
    fails, the group checkpoint is deactivated -- no redeems will be generated, so
    there is nothing to revert. Added isRedeem flag; RedeemFilter called via
    sequencingHooks directly (not hooks, which is nil for redeems).

  • execution/gethexec/sequencer.go: Added redeemFilter field to
    FullSequencingHooks and MakeSequencingHooks.
    FullSequencingHooks.RedeemFilter delegates to the sequencer's filter.
    FullSequencingHooks.ReportGroupRevert replaces the last txErrors entry
    with the cascading redeem error, excluding the tx from the block and returning
    the error to the RPC caller. Sequencer.redeemFilter applies event filter and
    checks IsAddressFiltered.


3. Event filter wiring for delayed messages

Problem

The event filter (scanning Transfer, TransferSingle, TransferBatch log topics
for filtered addresses) only ran in the sequencer's postTxFilter, not in
DelayedFilteringSequencingHooks.PostTxFilter. Delayed transactions that emit
events involving filtered addresses would pass through undetected.

Solution

Plumb the eventFilter through to DelayedFilteringSequencingHooks:

  • NewDelayedFilteringSequencingHooks now accepts an *eventfilter.EventFilter
    parameter, stored on the struct.
  • PostTxFilter calls applyEventFilter() after touching sender/recipient
    addresses, before the IsAddressFiltered() check.
  • RedeemFilter also calls applyEventFilter() so event-based address
    detection works for both direct delayed txs and their cascading redeems.
  • ExecutionEngine.SetEventFilter stores the filter;
    createBlockFromNextMessage passes it to the hooks constructor.

A shared applyEventFilter() helper iterates the current tx's logs and calls
TouchAddress for each address returned by
EventFilter.AddressesForFiltering.

Implementation

  • execution/gethexec/executionengine.go: Added eventFilter field to
    ExecutionEngine and DelayedFilteringSequencingHooks. Added
    applyEventFilter helper. Plumbed through from sequencer construction via
    SetEventFilter.

4. Delayed manual redeem filtering

Problem

A signed L2 transaction sent via the delayed inbox that calls
ArbRetryableTx.redeem() needs the same protection as auto-redeems. Without
it, delayed manual redeems could touch filtered addresses with no detection.

Solution

Handled by the same checkpoint-and-revert mechanism from section 2.
DelayedFilteringSequencingHooks implements both RedeemFilter (applies event
filter, checks IsAddressFiltered) and ReportGroupRevert (extracts
OriginatingTxHash from ErrFilteredCascadingRedeem, appends to
FilteredTxHashes to trigger the delayed sequencer halt).

Note that for delayed manual redeems, the OriginatingTxHash reported is the
hash of the L2 tx that called redeem(), not the retryable's ticketId. This is
correct -- the onchain filter needs the hash of the tx being replayed, not the
retryable it targets.

Implementation

  • execution/gethexec/executionengine.go:
    DelayedFilteringSequencingHooks.RedeemFilter applies event filter and checks
    IsAddressFiltered. ReportGroupRevert extracts OriginatingTxHash and
    appends it to FilteredTxHashes, triggering the delayed sequencer halt.

Other implementation notes

contracts-local/src/mocks/AddressFilterTest.sol

Made selfDestructTo function payable so tests can send ETH with the call.

Tests

17 new tests added to system_tests/delayed_message_filter_test.go.

Shared helpers setupRetryableFilterTest, submitRetryableViaL1, and
verifyCascadingRedeemFiltered reduce duplication across the 17 tests. Existing
tests updated to use advanceL1ForDelayed (renamed from
advanceAndWaitForDelayed, removes sleep in favor of explicit wait mechanisms).

Filtered retryable submissions (section 1)

  • TestFilteredRetryableRedirectWithExplicitRecipient -- filtered beneficiary redirected to explicit filteredFundsRecipient
  • TestFilteredRetryableRedirectFallbackToNetworkFee -- filtered beneficiary falls back to networkFeeAccount when no recipient set
  • TestFilteredRetryableNoRedirectWhenNotFiltered -- clean retryable passes through unaffected
  • TestFilteredRetryableWithCallValue -- redirection works correctly with non-zero call value in escrow
  • TestFilteredRetryableSequencerDoesNotReHalt -- sequencer processes subsequent delayed messages after resolving a filtered retryable

Cascading redeem checkpoint-and-revert (section 2)

  • TestRetryableAutoRedeemCallsFilteredAddress -- auto-redeem CALLs filtered contract, group reverted
  • TestRetryableAutoRedeemCreatesAtFilteredAddress -- auto-redeem CREATEs at filtered address, no contract deployed
  • TestRetryableAutoRedeemSelfDestructsToFilteredAddress -- auto-redeem SELFDESTRUCTs to filtered beneficiary, no ETH transferred
  • TestRetryableAutoRedeemStaticCallsFilteredAddress -- auto-redeem STATICCALLs filtered contract, group reverted
  • TestRetryableAutoRedeemEmitsTransferToFilteredAddress -- event filter detects filtered address in log topic during redeem
  • TestManualRedeemGroupRevert -- manual redeem via L2 tx (FullSequencingHooks path), error returned to RPC caller

Cascading redeem state safety (section 2)

  • TestRetryableGroupRevertDoesNotAffectCleanRetryable -- clean retryable in prior block unaffected by subsequent dirty retryable
  • TestSequentialRetryableGroupReverts -- two dirty retryables trigger independent halt/resolve cycles
  • TestRetryableGroupRevertWithChainedRedeems -- chained redeem (A redeems B) with filtered inner execution, entire chain reverted
  • TestRetryableGroupRevertSkipFinaliseSafety -- tentative storage writes fully rolled back after group revert

Delayed manual redeem filtering (section 4)

  • TestDelayedManualRedeemGroupRevert -- manual redeem via delayed L2 tx, proves originatingTxHash != ticketId

Event filter integration (section 3)

  • TestDelayedMessageFilterCatchesEventFilter -- event filter detects filtered address in Transfer event topic on delayed tx

pulls in OffchainLabs/go-ethereum#623
fixes NIT-4453

Extend address filtering to cover ArbitrumSubmitRetryableTx, retryable
redeem execution, and event-based filtering in the delayed message path.

What was missing
----------------

ArbitrumSubmitRetryableTx filtering: PostTxFilter touches sender and tx.To()
but not the retryable-specific fields (Beneficiary, FeeRefundAddr, RetryTo).
When the onchain filter contains the tx hash, StartTxHook had no handling
for the retryable case, so funds would flow to filtered addresses.

Redeem inner execution filtering: When a retryable is redeemed (auto or
manual), the ArbitrumRetryTx runs with hooks = nil in the block processor,
so PostTxFilter never fires. The EVM execution touches filtered addresses
via PushContract/opSelfdestruct but nobody checks IsAddressFiltered()
afterwards.

Event filter in delayed path: The event filter (Transfer, TransferSingle,
TransferBatch log scanning) only ran in the sequencer's postTxFilter, not
in DelayedFilteringSequencingHooks.PostTxFilter.

Solution
--------

Filtered retryable redirect: In StartTxHook for ArbitrumSubmitRetryableTx,
when the tx hash is in the onchain filter, redirect Beneficiary and
FeeRefundAddr to a configurable filteredFundsRecipient (new ArbOS state
field, with ArbOwner precompile accessors, fallback to networkFeeAccount).
Skip auto-redeem scheduling. Set ErrFilteredTx as result.Err so PostTxFilter
knows to skip re-halting.

RedeemFilter: New RedeemFilter(*state.StateDB) error method on the
SequencingHooks interface. Called in the block processor's result filter
closure when the current tx is a redeem. Runs the event filter on logs then
checks IsAddressFiltered(). Returns ErrArbTxFilter to revert the snapshot
and drop the redeem from the block.

Delayed event filter: Pass the event filter to
DelayedFilteringSequencingHooks. Shared applyEventFilter() helper called in
both PostTxFilter and RedeemFilter.

PostTxFilter retryable field touching: New touchRetryableAddresses() helper
touches Beneficiary, FeeRefundAddr, RetryTo, and their de-aliased versions
(InverseRemapL1Address). Called in both sequencer and delayed PostTxFilter.

Design Decisions
----------------

Redirect instead of reject: Retryable submissions are L1 delayed messages
that cannot be rejected. Funds are already deposited on L2. Rejecting would
leave them stuck in escrow with an unreachable beneficiary.

Skip auto-redeem for filtered retryables: The RetryData calldata may target
filtered addresses. The redirected beneficiary can manually redeem if
appropriate.

ErrFilteredTx in result.Err: Without this marker, PostTxFilter sees the
original (still-filtered) Beneficiary via touchRetryableAddresses and
re-halts. The error signals that the onchain filter already handled this tx.

RedeemFilter via sequencingHooks not hooks: hooks is intentionally nil for
redeems - it gates sequencer policies (PreTxFilter nonce checking,
PostTxFilter nonce cache updates/revert gas rejection, InsertLastTxError,
DiscardInvalidTxsEarly) that don't apply to protocol-scheduled transactions.
RedeemFilter is called on sequencingHooks (the function parameter, always
non-nil) directly to get only the narrow redeem filtering behavior.

Dropping redeems is safe: State reverts via RevertToSnapshot. The retryable
ticket survives (DeleteRetryable only runs on successful redeem in
EndTxHook). Ticket can be manually redeemed later or expires to beneficiary.
This is a sequencing-level decision - NoopSequencingHooks.RedeemFilter
returns nil during replay/validation.

De-aliased address touching: The L1 Inbox aliases contract addresses for
Beneficiary and FeeRefundAddr. We touch both the aliased and original
(InverseRemapL1Address) versions so filtering catches the L1 address.

DeleteFree commented out: For symmetry with other filtered tx paths,
deletion from the onchain filter is handled by the external tx authority
service.

Tests (11 new):
---------------

Retryable redirect (halt-and-wait pattern):
- TestFilteredRetryableRedirectWithExplicitRecipient
- TestFilteredRetryableRedirectFallbackToNetworkFee
- TestFilteredRetryableNoRedirectWhenNotFiltered
- TestFilteredRetryableWithCallValue
- TestFilteredRetryableSequencerDoesNotReHalt

RedeemFilter (verify redeem dropped, ticket survives):
- TestRetryableAutoRedeemCallsFilteredAddress
- TestRetryableAutoRedeemCreatesAtFilteredAddress
- TestRetryableAutoRedeemSelfDestructsToFilteredAddress
- TestRetryableAutoRedeemStaticCallsFilteredAddress
- TestRetryableAutoRedeemEmitsTransferToFilteredAddress
- TestDelayedMessageFilterCatchesEventFilter

Delayed event filter:
- TestDelayedMessageFilterCatchesEventFilter
@codecov
Copy link

codecov bot commented Feb 9, 2026

Codecov Report

❌ Patch coverage is 60.00000% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 32.76%. Comparing base (0b4a8fa) to head (37a28d0).
⚠️ Report is 20 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4352      +/-   ##
==========================================
- Coverage   34.73%   32.76%   -1.98%     
==========================================
  Files         489      493       +4     
  Lines       58125    58457     +332     
==========================================
- Hits        20190    19151    -1039     
- Misses      34347    35960    +1613     
+ Partials     3588     3346     -242     

@github-actions
Copy link
Contributor

github-actions bot commented Feb 10, 2026

❌ 393 Tests Failed:

Tests completed Failed Passed Skipped
4022 393 3629 0
View the top 3 failed tests by shortest run time
TestArbos11To32UpgradeWithCalldata
Stack Traces | -0.000s run time
=== RUN   TestArbos11To32UpgradeWithCalldata
=== PAUSE TestArbos11To32UpgradeWithCalldata
TestBatchPosterPostsReportOnlyBatchAfterMaxEmptyBatchDelay
Stack Traces | -0.000s run time
=== RUN   TestBatchPosterPostsReportOnlyBatchAfterMaxEmptyBatchDelay
=== PAUSE TestBatchPosterPostsReportOnlyBatchAfterMaxEmptyBatchDelay
TestSetFinalityBlockHashMismatch
Stack Traces | -0.000s run time
... [CONTENT TRUNCATED: Keeping last 20 lines]
WARN [02-24|18:55:59.094] XClaimJustID returned empty response when indicating heartbeat msgID=1771959358576-0
WARN [02-24|18:55:59.104] Served eth_getTransactionReceipt         reqid=58 duration="46.788µs"  err="transaction indexing is in progress" errdata="\"transaction indexing is in progress\""
WARN [02-24|18:55:59.110] XClaimJustID returned empty response when indicating heartbeat msgID=1771959358576-0
WARN [02-24|18:55:59.126] XClaimJustID returned empty response when indicating heartbeat msgID=1771959358576-0
WARN [02-24|18:55:59.126] Served eth_getTransactionReceipt         reqid=59 duration="42.379µs"  err="transaction indexing is in progress" errdata="\"transaction indexing is in progress\""
WARN [02-24|18:55:59.141] XClaimJustID returned empty response when indicating heartbeat msgID=1771959358576-0
WARN [02-24|18:55:59.147] Served eth_getTransactionReceipt         reqid=60 duration="28.233µs"  err="transaction indexing is in progress" errdata="\"transaction indexing is in progress\""
INFO [02-24|18:55:59.156] Starting peer-to-peer node               instance=test-stack-name/linux-amd64/go1.25.6
WARN [02-24|18:55:59.156] P2P server will be useless, neither dialing nor listening
WARN [02-24|18:55:59.157] XClaimJustID returned empty response when indicating heartbeat msgID=1771959358576-0
INFO [02-24|18:55:59.159] New local node record                    seq=1,771,959,359,158 id=d791d0f4e673b5c0 ip=127.0.0.1 udp=0 tcp=0
INFO [02-24|18:55:59.159] Started P2P networking                   self=enode://d06ddd84310962654506f376e3f64b900ed1cecf30eeefc8b4138597168a1f98f3dd9b209bc24515052243d320dd3d32b37470f7ea927622b5311ced9537467b@127.0.0.1:0
INFO [02-24|18:55:59.159] Started log indexer
WARN [02-24|18:55:59.159] Getting file info                        dir= error="stat : no such file or directory"
    common_test.go:733: BuildL1 deployConfig: DeployBold=true, DeployReferenceDAContracts=false
INFO [02-24|18:56:09.632] Started log indexer
INFO [02-24|18:56:09.633] Submitted transaction                    hash=0xdbec57a1293770f7bfb995d67ede06776374d4008600e74c8578200d1ebc8e45 from=0xaF24Ca6c2831f4d4F629418b50C227DF0885613A nonce=62  recipient=0x616702504d006164ce7E35EBEf008D6B3e983D3B value=1,000,000,000,000,000,000,000,000
INFO [02-24|18:56:09.635] Starting work on payload                 id=0x0380d3076c5c7b4e
WARN [02-24|18:56:09.635] Getting file info                        dir= error="stat : no such file or directory"
INFO [02-24|18:56:09.636] Submitted transaction                    hash=0xd0a5d8249e3f9a349b1bacbf7d0414552cb346e2f36805780cd3dc46f6590433 from=0xaF24Ca6c2831f4d4F629418b50C227DF0885613A nonce=195 recipient=0xaF24Ca6c2831f4d4F629418b50C227DF0885613A value=1

📣 Thoughts on this report? Let Codecov know! | Powered by Codecov

Base automatically changed from filtered-funds-recipient to master February 10, 2026 15:22
@Tristan-Wilson Tristan-Wilson marked this pull request as draft February 11, 2026 18:37
@diegoximenes
Copy link
Contributor

diegoximenes commented Feb 12, 2026

Removing myself from assigned set while this is in draft.

@diegoximenes diegoximenes removed their assignment Feb 12, 2026
When a retryable auto-redeem's inner execution touches a filtered address,
simply dropping the redeem causes consensus divergence: redeems are generated
inside ProduceBlockAdvanced via ScheduledTxes(), so during replay the redeem
re-executes (NoopSequencingHooks.RedeemFilter returns nil), producing a
different state root than the sequencer's block.

The fix is checkpoint-and-revert: take a state snapshot before each user tx
and process it with all its redeems tentatively (skipFinalise). If any redeem
triggers RedeemFilter, revert the entire group (user tx + all redeems) so the
redeem is never generated in the first place. Both sequencer and replay then
see the same block without the tx, maintaining consensus.

For the delayed path, the group revert reports the originating tx hash via
ReportGroupRevert, which halts the delayed sequencer. The the hash is added to
the onchain filter via the transaction-filterer service, and the
submission re-processes with redirected beneficiary and no auto-redeem.

Key design decisions:
- skipFinalise defers statedb.Finalise during tentative group processing so
  that RevertToSnapshot can cleanly undo the entire group across tx boundaries
  (Finalise destroys the journal and promotes dirtyStorage to pendingStorage,
  making cross-tx revert impossible)
- SubRefund drains the EVM refund counter before each tx in a tentative group,
  mimicking what Finalise normally does - without this, the leaked refund
  causes GasUsed divergence (consensus break). SubRefund is journaled so group
  revert restores it automatically
- ReportGroupRevert is a new SequencingHooks method that lets the block
  processor signal a group revert to the hooks layer without coupling to
  specific hook implementations
if err != nil {
// Cascading redeem filtering: if a redeem was filtered and we have an
// active group checkpoint, revert the entire group (user tx + all redeems)
if isRedeem && activeGroupCP != nil && errors.Is(err, state.ErrArbTxFilter) {
Copy link
Contributor

Choose a reason for hiding this comment

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

(as discussed)
use revertGroupCheckpoint even for non-redeems. They'll just be a group of size 1.

Copy link
Member Author

Choose a reason for hiding this comment

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

Any outer error now triggers revertToGroupCheckpoint unconditionally. revertToGroupCheckpoint is split from ReportGroupRevert so that ReportGroupRevert is only called for filter errors on redeems. Fixed in 696d550

}

// Take group checkpoint before processing user tx
if isUserTx {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it'll work better if you start a group for any non-redeem.
The one other case is first tx, which can only revert if things go very wrong, and and never issues a redeem - but having a group for firstTx will make it easy to always revert groups.

Copy link
Member Author

Choose a reason for hiding this comment

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

Every non-redeem now gets a group checkpoint, fixed in 696d550

// sequencingHooks directly to get the narrow redeem filtering behavior
// without enabling those other policies.
if isRedeem {
return sequencingHooks.RedeemFilter(statedb)
Copy link
Contributor

Choose a reason for hiding this comment

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

(as discussed)
Try to still use hooks.PostTxFilter for redeem.
That will mean not setting hooks to nil, and replacing all the places of checking if hooks is nil with adding to hooks a function like "nextTxIsRedeem" or something and have hooks act accordingly. Possibly block_processotr can also just check directly isRedeem

Copy link
Member Author

Choose a reason for hiding this comment

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

Redeems now go through PostTxFilter with isRedeem=true instead of the separate RedeemFilter. hooks is no longer set to nil for redeems; the 5 sites that used hooks != nil as a proxy for "is user tx" now check isUserTx directly. Fixed in 696d550

1. Universal group checkpoints: every non-redeem tx (including firstTx)
now gets a group checkpoint, not just user txs. Since activeGroupCP is
now always non-nil during tx processing, its nil guards on SubRefund,
SkipFinalise, and the error handling block are removed.

2. Uniform group revert for all errors: any outer error now triggers
revertToGroupCheckpoint, not just filter errors on redeems.
revertToGroupCheckpoint is split from ReportGroupRevert so callers
control reporting: filter errors on redeems call ReportGroupRevert
(replacing the user tx's nil txErrors entry with
ErrFilteredCascadingRedeem), while filter errors on user txs and
non-filter errors skip it (preserving the correct error already in
txErrors from InsertLastTxError).

3. Route redeems through PostTxFilter: redeems now use
hooks.PostTxFilter(isRedeem=true) instead of the separate RedeemFilter
method. hooks is no longer set to nil for redeems; the 5 sites that
used "hooks != nil" as a proxy for "is user tx" now check isUserTx
directly. RedeemFilter is removed from the SequencingHooks interface.
Tristan-Wilson and others added 4 commits February 17, 2026 19:25
The skipFinalise parameter was being passed unconditionally as true for
all ArbOS versions, which breaks consensus for pre-ArbOS 60 blocks. The
issue is that skipping Finalise between the submit-retryable tx and its
auto-redeem prevents the CreateZombieIfDeleted mechanism from firing:
without the intermediate Finalise, empty escrow accounts are never added
to stateObjectsDestruct, so the zombie preservation logic (needed for
ArbOS < 30) doesn't trigger. This causes empty retryable escrow accounts
to be deleted at end-of-block Finalise, diverging from the state produced
by existing nodes that call Finalise between every tx.

The group checkpoint mechanism (skipFinalise, SubRefund drain, group
revert) exists solely for the cascading redeem filtering feature, which
is gated behind ArbosVersion_TransactionFiltering (60). This commit gates
the entire mechanism behind the same version check, preserving the legacy
per-tx Finalise behavior for older ArbOS versions.

- Add useGroupCheckpoints flag based on ArbOS version >= 60
- Conditionally create group checkpoints and skip Finalise only when flag is set
- Restore original non-zero refund fatal error for older ArbOS versions
- Guard group revert logic behind activeGroupCP != nil check
@Tristan-Wilson Tristan-Wilson changed the base branch from master to filter-submit-retryable-outer-tx-only February 18, 2026 19:33
Base automatically changed from filter-submit-retryable-outer-tx-only to master February 19, 2026 01:16
@Tristan-Wilson Tristan-Wilson changed the title Address filtering for retryable submissions, redeems, and delayed events Address filtering for redeems Feb 19, 2026
// For non-redeems: touches To/From addresses, applies event filter, and collects
// tx hashes that touch filtered addresses but are not in the onchain filter.
func (f *DelayedFilteringSequencingHooks) PostTxFilter(header *types.Header, db *state.StateDB, a *arbosState.ArbosState, tx *types.Transaction, sender common.Address, dataGas uint64, result *core.ExecutionResult, isRedeem bool) error {
if isRedeem {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don;t think this if (or even bool input) is necessary? can redeems can use the same logic here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Got rid of the bool param in bd91bc3 and moved the gating of special logic for redeems/non redeems later in 63d3ef4

PostTxFilter(*types.Header, *state.StateDB, *arbosState.ArbosState, *types.Transaction, common.Address, uint64, *core.ExecutionResult, bool) error
BlockFilter(*types.Header, *state.StateDB, types.Transactions, types.Receipts) error
InsertLastTxError(error)
ReportGroupRevert(error)
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's use this chance to solidify the API a little.
InsertLastTxError and ReportGroupRevert should be merged to a single function.. something like DesequenceLastTx(err).
And now instead of api being "must call InsertLastTxError exactly once after every "NextTxToSequence" It'll just be "DesequenceLastTx only handles last NextTxToSequence". That'll require some restructure of other parts but worth it.

Copy link
Contributor

Choose a reason for hiding this comment

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

I also think that DesequenceLastTx could return a boolean (true/false) that says if the tx was really desequenced and that'll replace DiscardInvalidTxsEarly

Copy link
Member Author

Choose a reason for hiding this comment

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

I ended up with three methods instead of two, but shifted around the responsibilities in a way that I think is cleaner than the original.

  • TxSucceeded() - records success (replacing InsertLastTxError(nil))
  • TxFailed(error) - records failure (replacing InsertLastTxError(err) and ReportGroupRevert(err)
  • CanDiscardTx() bool - this is just a renaming of DiscardInvalidTxsEarly() to make it clear it's a static propery

I tried merging TxFailed and CanDiscardTx into a single DesequenceLastTx(error) bool as you suggested but I felt like the name was misleading because it didn't actually desequence anything, it just recorded the error and returned whether to discard the tx or not. I think splitting them makes it read more naturally.

Here's the change 6298406

// between txs so each starts with refund=0. A nonzero starting refund
// here would cause GasUsed divergence (consensus break). SubRefund
// drains the counter to 0, mimicking Finalise. It's journaled, so
// group revert undoes it.
Copy link
Contributor

Choose a reason for hiding this comment

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

good, very necessary comment. thanks!

// older versions would break consensus by changing empty-account
// lifecycle behavior (CreateZombieIfDeleted depends on intermediate
// Finalise calls to populate stateObjectsDestruct).
useGroupCheckpoints := arbState.ArbOSVersion() >= params.ArbosVersion_TransactionFiltering
Copy link
Contributor

Choose a reason for hiding this comment

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

if the problem is with zombie accounts, they were fixed after arbos 30, so keep the comment but use arbos30 as limit. I will want to see re-execution / validation of old arbos with that logic to make sure we're not introducing any new weird behavior

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed in 746118f

The tests TestSubmitRetryableEmptyEscrowArbOS20 and TestSubmitRetryableEmptyEscrowArbOS30 fail without this gate and are passing now. Do you think this is enough or is there more you think we should test?

func(result *core.ExecutionResult) error {
if hooks != nil {
return hooks.PostTxFilter(header, statedb, arbState, tx, sender, dataGas, result)
if hooks != nil { // nil only for firstTx (ArbitrumInternalTxType)
Copy link
Contributor

Choose a reason for hiding this comment

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

I really don't like that there is even a difference between "hooks" and "sequencinghooks". We should always use the same hooks in the loop.
We can just make sure PostTxFilter will always return nil for ArbitrumInternalTxType because we never want to filer these

Copy link
Member Author

Choose a reason for hiding this comment

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

Removed the separate hooks var and checking for ArbitrumInternalTxType in the impls 60b5372

blockGasLeft = activeGroupCP.blockGasLeft
expectedBalanceDelta.Set(activeGroupCP.expectedBalanceDelta)
complete = complete[:activeGroupCP.completeLen]
receipts = receipts[:activeGroupCP.receiptsLen]
Copy link
Contributor

Choose a reason for hiding this comment

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

I keep thinking if there's a way to do the opposite, instead of removing items from complete/receipts in case of failure, storing separate "current group" arrays and appending them to the main array in case of success.
not sure this is a better direction.. but sharing it here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I tried it out and I think it looks slightly nicer because I could roll the statedb.Finalize in with it ca7d4d9

mahdy-nasr and others added 14 commits February 23, 2026 11:52
…github.com:OffchainLabs/nitro into add-retryable-test-scenarios-for-address-filtering
PostTxFilter implementations can derive this from the tx type
(ArbitrumRetryTxType) instead of receiving it as a parameter.
Both implementations now run TouchAddress, alias de-aliasing, and event
filter application unconditionally for all tx types. The isRedeem early
return moves below the shared section, before non-redeem-specific logic
(nonce cache, revert gas rejection, filtered tx hash collection).
TestSubmitRetryableEmptyEscrowArbOS20 and ArbOS30 both pass.
Instead of appending to complete/receipts during tentative group
processing and truncating on revert, accumulate in separate
groupComplete/groupReceipts slices and promote them on commit.
Always use sequencingHooks directly. PostTxFilter implementations now
return nil for ArbitrumInternalTxType instead of relying on a nil
hooks guard at the call site.
Split the old InsertLastTxError/ReportGroupRevert/DiscardInvalidTxsEarly
into TxSucceeded/TxFailed/CanDiscardTx so success and failure are recorded
on separate code paths instead of always appending then overwriting.
…os-for-address-filtering

add retrylabe tests scenarios for address filtering
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants