Skip to content

Commit 66d78e5

Browse files
Implement checkpoint-and-revert for cascading redeem filtering
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
1 parent 976a34f commit 66d78e5

File tree

7 files changed

+863
-181
lines changed

7 files changed

+863
-181
lines changed

arbos/block_processor.go

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ func createNewHeader(prevHeader *types.Header, l1info *L1Info, baseFee *big.Int,
111111

112112
type ConditionalOptionsForTx []*arbitrum_types.ConditionalOptions
113113

114+
// ErrFilteredCascadingRedeem is returned via ReportGroupRevert when a redeem's
115+
// inner execution touches a filtered address, requiring the entire tx group
116+
// (originating user tx + all its redeems) to be reverted.
117+
type ErrFilteredCascadingRedeem struct {
118+
OriginatingTxHash common.Hash
119+
}
120+
121+
func (e *ErrFilteredCascadingRedeem) Error() string {
122+
return fmt.Sprintf("cascading redeem filtered: originating tx %s", e.OriginatingTxHash.Hex())
123+
}
124+
114125
type SequencingHooks interface {
115126
NextTxToSequence() (*types.Transaction, *arbitrum_types.ConditionalOptions, error)
116127
DiscardInvalidTxsEarly() bool
@@ -119,6 +130,7 @@ type SequencingHooks interface {
119130
RedeemFilter(*state.StateDB) error
120131
BlockFilter(*types.Header, *state.StateDB, types.Transactions, types.Receipts) error
121132
InsertLastTxError(error)
133+
ReportGroupRevert(error)
122134
}
123135

124136
type NoopSequencingHooks struct {
@@ -160,6 +172,8 @@ func (n *NoopSequencingHooks) RedeemFilter(db *state.StateDB) error {
160172

161173
func (n *NoopSequencingHooks) InsertLastTxError(err error) {}
162174

175+
func (n *NoopSequencingHooks) ReportGroupRevert(error) {}
176+
163177
func NewNoopSequencingHooks(txes types.Transactions) *NoopSequencingHooks {
164178
return &NoopSequencingHooks{txs: txes}
165179
}
@@ -252,6 +266,50 @@ func ProduceBlockAdvanced(
252266

253267
firstTx := types.NewTx(startTx)
254268

269+
// Group checkpoint state for cascading redeem filtering. We take a state
270+
// checkpoint before each user tx and process it with all its redeems
271+
// tentatively (skipFinalise). If any redeem hits RedeemFilter, we revert
272+
// the entire group. If all redeems are clean, we flush Finalise.
273+
// lint:require-exhaustive-initialization
274+
type groupCheckpoint struct {
275+
snap int
276+
headerGasUsed uint64
277+
blockGasLeft uint64
278+
expectedBalanceDelta *big.Int
279+
completeLen int
280+
receiptsLen int
281+
userTxsProcessed int
282+
gethGas core.GasPool
283+
userTxHash common.Hash
284+
}
285+
var activeGroupCP *groupCheckpoint
286+
287+
// revertToGroupCheckpoint reverts statedb and non-statedb state to the group
288+
// checkpoint, reports the cascading redeem error to sequencing hooks, and
289+
// deactivates the group. Returns an error only on fatal failures.
290+
revertToGroupCheckpoint := func() error {
291+
statedb.RevertToSnapshot(activeGroupCP.snap)
292+
statedb.ClearTxFilter()
293+
header.GasUsed = activeGroupCP.headerGasUsed
294+
blockGasLeft = activeGroupCP.blockGasLeft
295+
expectedBalanceDelta.Set(activeGroupCP.expectedBalanceDelta)
296+
complete = complete[:activeGroupCP.completeLen]
297+
receipts = receipts[:activeGroupCP.receiptsLen]
298+
userTxsProcessed = activeGroupCP.userTxsProcessed
299+
gethGas = activeGroupCP.gethGas
300+
redeems = redeems[:0]
301+
var reopenErr error
302+
arbState, reopenErr = arbosState.OpenSystemArbosState(statedb, nil, true)
303+
if reopenErr != nil {
304+
return reopenErr
305+
}
306+
sequencingHooks.ReportGroupRevert(&ErrFilteredCascadingRedeem{
307+
OriginatingTxHash: activeGroupCP.userTxHash,
308+
})
309+
activeGroupCP = nil
310+
return nil
311+
}
312+
255313
for {
256314
// repeatedly process the next tx, doing redeems created along the way in FIFO order
257315

@@ -272,12 +330,19 @@ func ProduceBlockAdvanced(
272330
if !ok {
273331
return nil, nil, errors.New("retryable tx is somehow not a retryable")
274332
}
333+
275334
retryable, _ := arbState.RetryableState().OpenRetryable(retry.TicketId, time)
276335
if retryable == nil {
277336
// retryable was already deleted
278337
continue
279338
}
280339
} else {
340+
// Flush previous clean group's deferred Finalise before starting new work
341+
if activeGroupCP != nil {
342+
statedb.Finalise(true)
343+
activeGroupCP = nil
344+
}
345+
281346
var conditionalOptions *arbitrum_types.ConditionalOptions
282347
tx, conditionalOptions, err = sequencingHooks.NextTxToSequence()
283348
if err != nil {
@@ -291,11 +356,36 @@ func ProduceBlockAdvanced(
291356
isUserTx = true
292357
options = conditionalOptions
293358
}
359+
360+
// Take group checkpoint before processing user tx
361+
if isUserTx {
362+
activeGroupCP = &groupCheckpoint{
363+
snap: statedb.Snapshot(),
364+
headerGasUsed: header.GasUsed,
365+
blockGasLeft: blockGasLeft,
366+
expectedBalanceDelta: new(big.Int).Set(expectedBalanceDelta),
367+
completeLen: len(complete),
368+
receiptsLen: len(receipts),
369+
userTxsProcessed: userTxsProcessed,
370+
gethGas: gethGas,
371+
userTxHash: tx.Hash(),
372+
}
373+
}
294374
}
295375

376+
// Without Finalise between txs in a tentative group, the EVM refund
377+
// counter leaks across tx boundaries. During replay, Finalise IS called
378+
// between txs so each starts with refund=0. A nonzero starting refund
379+
// here would cause GasUsed divergence (consensus break). SubRefund
380+
// drains the counter to 0, mimicking Finalise. It's journaled, so
381+
// group revert undoes it.
296382
startRefund := statedb.GetRefund()
297383
if startRefund != 0 {
298-
return nil, nil, fmt.Errorf("at beginning of tx statedb has non-zero refund %v", startRefund)
384+
if activeGroupCP != nil {
385+
statedb.SubRefund(startRefund)
386+
} else {
387+
return nil, nil, fmt.Errorf("at beginning of tx statedb has non-zero refund %v", startRefund)
388+
}
299389
}
300390

301391
var sender common.Address
@@ -392,6 +482,15 @@ func ProduceBlockAdvanced(
392482
}
393483
return nil
394484
},
485+
// skipFinalise: Normally Finalise runs after every committed tx,
486+
// promoting dirtyStorage -> pendingStorage, clearing the journal,
487+
// and zeroing the refund counter. After that, RevertToSnapshot
488+
// can't undo past that boundary (journal gone, pendingStorage not
489+
// journaled, snapshot IDs invalidated). We need to revert the
490+
// entire group if any redeem is filtered, so we skip Finalise
491+
// while a group checkpoint is active. It's flushed at group
492+
// boundaries: before the next user tx or at end of block.
493+
activeGroupCP != nil,
395494
)
396495
if err != nil {
397496
// Ignore this transaction if it's invalid under the state transition function
@@ -416,6 +515,18 @@ func ProduceBlockAdvanced(
416515
}
417516

418517
if err != nil {
518+
// Cascading redeem filtering: if a redeem was filtered and we have an
519+
// active group checkpoint, revert the entire group (user tx + all redeems)
520+
if isRedeem && activeGroupCP != nil && errors.Is(err, state.ErrArbTxFilter) {
521+
if err := revertToGroupCheckpoint(); err != nil {
522+
return nil, nil, err
523+
}
524+
continue
525+
}
526+
// If the user tx itself failed, deactivate the group (no redeems generated)
527+
if isUserTx && activeGroupCP != nil {
528+
activeGroupCP = nil
529+
}
419530
logLevel := log.Debug
420531
if chainConfig.DebugMode() {
421532
logLevel = log.Warn
@@ -527,6 +638,12 @@ func ProduceBlockAdvanced(
527638
}
528639
}
529640

641+
// Flush deferred Finalise for the last clean group
642+
if activeGroupCP != nil {
643+
statedb.Finalise(true)
644+
activeGroupCP = nil
645+
}
646+
530647
if statedb.IsTxFiltered() {
531648
return nil, nil, state.ErrArbTxFilter
532649
}

arbos/tx_processor.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,7 @@ func (p *TxProcessor) StartTxHook() (endTxNow bool, multiGasUsed multigas.MultiG
214214
// already handled by the onchain filter and skip halting.
215215
var filteredErr error
216216
isFiltered := false
217-
if p.state.ArbOSVersion() >= params.ArbosVersion_TransactionFiltering &&
218-
p.state.FilteredTransactions().IsFilteredFree(ticketId) {
217+
if p.state.FilteredTransactions().IsFilteredFree(ticketId) {
219218
recipient, err := p.state.FilteredFundsRecipientOrDefault()
220219
if err != nil {
221220
return true, multigas.ZeroGas(), err, nil
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
### Added
2+
- Checkpoint-and-revert mechanism for filtering retryable submissions whose redeems touch filtered addresses.

execution/gethexec/executionengine.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ func (f *DelayedFilteringSequencingHooks) PostTxFilter(header *types.Header, db
118118
if tx.To() != nil {
119119
db.TouchAddress(*tx.To())
120120
}
121+
// For tx types that alias the sender (unsigned contract txs, retryables),
122+
// also check the original L1 address. The sender in the tx is already
123+
// aliased by the L1 bridge, but the restricted address list contains
124+
// original (non-aliased) addresses.
121125
txType := tx.Type()
122126
if arbosutil.DoesTxTypeAlias(&txType) {
123127
db.TouchAddress(arbosutil.InverseRemapL1Address(sender))
@@ -147,6 +151,17 @@ func (f *DelayedFilteringSequencingHooks) RedeemFilter(db *state.StateDB) error
147151
return nil
148152
}
149153

154+
// ReportGroupRevert extracts the originating tx hash from ErrFilteredCascadingRedeem
155+
// and appends it to FilteredTxHashes. After ProduceBlockAdvanced returns, the existing
156+
// check at executionengine.go fires ErrFilteredDelayedMessage, causing the delayed
157+
// sequencer to halt and the transaction-filterer to add the hash to the onchain filter.
158+
func (f *DelayedFilteringSequencingHooks) ReportGroupRevert(err error) {
159+
var cascadingErr *arbos.ErrFilteredCascadingRedeem
160+
if errors.As(err, &cascadingErr) {
161+
f.FilteredTxHashes = append(f.FilteredTxHashes, cascadingErr.OriginatingTxHash)
162+
}
163+
}
164+
150165
func applyEventFilter(ef *eventfilter.EventFilter, db *state.StateDB) {
151166
if ef == nil {
152167
return

execution/gethexec/sequencer.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,6 @@ func (s *Sequencer) postTxFilter(header *types.Header, statedb *state.StateDB, _
842842
}
843843
}
844844
}
845-
touchRetryableAddresses(statedb, tx)
846845

847846
if statedb.IsTxFiltered() || statedb.IsAddressFiltered() {
848847
return state.ErrArbTxFilter
@@ -1153,6 +1152,20 @@ func (s *FullSequencingHooks) BlockFilter(header *types.Header, db *state.StateD
11531152
return nil
11541153
}
11551154

1155+
// ReportGroupRevert replaces the last txErrors entry with the group revert
1156+
// error. Redeems don't get txErrors entries (only user txs from
1157+
// NextTxToSequence do), so a group (user tx + all its redeems) has exactly
1158+
// one entry - the originating user tx's nil. Replacing it with the error
1159+
// excludes the tx from the block (MessageFromTxes skips non-nil entries)
1160+
// and returns the error to the RPC caller via resultChan.
1161+
func (s *FullSequencingHooks) ReportGroupRevert(err error) {
1162+
if len(s.txErrors) > 0 {
1163+
s.txErrors[len(s.txErrors)-1] = err
1164+
} else {
1165+
log.Error("ReportGroupRevert called with empty txErrors")
1166+
}
1167+
}
1168+
11561169
func MakeSequencingHooks(
11571170
items []txQueueItem,
11581171
maxSequencedTxsSize int,

0 commit comments

Comments
 (0)