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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tx-submitter/constants/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ const (
func IsCommitLikeMethod(method string) bool {
return method == MethodCommitBatch || method == MethodCommitState
}

// IsRollupMethod returns true for L1 rollup operations tracked by the submitter.
func IsRollupMethod(method string) bool {
return IsCommitLikeMethod(method) || method == MethodFinalizeBatch
}
25 changes: 25 additions & 0 deletions tx-submitter/constants/methods_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package constants

import "testing"

func TestIsRollupMethod(t *testing.T) {
t.Parallel()
tests := []struct {
method string
want bool
}{
{MethodCommitBatch, true},
{MethodCommitState, true},
{MethodFinalizeBatch, true},
{"transfer", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.method, func(t *testing.T) {
t.Parallel()
if got := IsRollupMethod(tt.method); got != tt.want {
t.Fatalf("IsRollupMethod(%q) = %v, want %v", tt.method, got, tt.want)
}
})
}
}
39 changes: 29 additions & 10 deletions tx-submitter/services/rollup.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,15 +652,25 @@ func (r *Rollup) handleDiscardedTx(txRecord *types.TxRecord, tx *ethtypes.Transa
return nil
}

// If resubmit failed, try to replace it with a simple transfer transaction
log.Warn("Resubmit failed, attempting to replace with simple transfer transaction",
"hash", tx.Hash().String(),
"nonce", tx.Nonce(),
"error", err)

replacedTx, err = r.createReplacementTransferTx(tx)
if err != nil {
return fmt.Errorf("failed to create replacement transfer tx: %w", err)
if constants.IsRollupMethod(method) {
log.Warn("Resubmit failed for rollup tx, retrying with fee bump and rebuild",
"hash", tx.Hash().String(),
"nonce", tx.Nonce(),
"method", method,
"error", err)
replacedTx, err = r.tryRecoverDiscardedRollupTx(tx)
if err != nil {
return fmt.Errorf("failed to recover discarded rollup tx: %w", err)
}
} else {
log.Warn("Resubmit failed, attempting to replace with simple transfer transaction",
"hash", tx.Hash().String(),
"nonce", tx.Nonce(),
"error", err)
replacedTx, err = r.createReplacementTransferTx(tx)
if err != nil {
return fmt.Errorf("failed to create replacement transfer tx: %w", err)
}
}
}

Expand Down Expand Up @@ -1855,8 +1865,17 @@ func (r *Rollup) CancelTx(tx *ethtypes.Transaction) (*ethtypes.Transaction, erro
return newTx, nil
}

// tryRecoverDiscardedRollupTx re-submits a discarded commit/finalize tx with fee
// bumps and commit rebuild logic. Rollup operations must never fall back to an
// empty-calldata self-transfer, which would consume the nonce without landing
// the batch on L1.
func (r *Rollup) tryRecoverDiscardedRollupTx(tx *ethtypes.Transaction) (*ethtypes.Transaction, error) {
return r.ReSubmitTx(false, tx)
}

// createReplacementTransferTx creates a simple transfer transaction with the same nonce
// to replace the original transaction. This is used when resubmission fails.
// to replace the original transaction. This is used when resubmission fails for
// non-rollup pending transactions only.
func (r *Rollup) createReplacementTransferTx(tx *ethtypes.Transaction) (*ethtypes.Transaction, error) {
if tx == nil {
return nil, errors.New("nil tx")
Expand Down
38 changes: 38 additions & 0 deletions tx-submitter/services/rollup_handle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"morph-l2/bindings/bindings"
"morph-l2/common/batch"
"morph-l2/tx-submitter/constants"
"morph-l2/tx-submitter/iface"
"morph-l2/tx-submitter/metrics"
"morph-l2/tx-submitter/mock"
Expand Down Expand Up @@ -177,6 +178,43 @@ func TestHandleDiscardedTx(t *testing.T) {
require.Equal(t, 1, len(r.pendingTxs.GetAll()), "New transaction should be added to pending pool")
}

func TestHandleDiscardedTxRollupDoesNotReplaceWithTransfer(t *testing.T) {
r, l1Mock, _, _ := setupTestRollup(t)

batchInput := bindings.IRollupBatchDataInput{
Version: 1,
ParentBatchHeader: make([]byte, 9),
LastBlockNumber: 10,
}
calldata, err := r.abi.Pack("commitBatch", batchInput)
require.NoError(t, err)

tx := ethtypes.NewTx(&ethtypes.DynamicFeeTx{
ChainID: r.chainId,
Nonce: 3,
GasTipCap: big.NewInt(1e9),
GasFeeCap: big.NewInt(2e9),
Gas: 100_000,
To: &r.rollupAddr,
Data: calldata,
})
txRecord := &types.TxRecord{
Tx: tx,
SendTime: uint64(time.Now().Unix()),
QueryTimes: 5,
}
require.NoError(t, r.pendingTxs.Add(tx))

l1Mock.SendTxErr = errors.New("send failed")
err = r.handleDiscardedTx(txRecord, tx, constants.MethodCommitBatch)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to recover discarded rollup tx")

pending := r.pendingTxs.GetAll()
require.Len(t, pending, 1, "original rollup tx must remain tracked when recovery fails")
require.NotEmpty(t, pending[0].Tx.Data(), "rollup calldata must not be replaced by an empty transfer")
}

// TestHandleReorg tests the handling of chain reorganizations
func TestHandleReorg(t *testing.T) {
r, _, _, _ := setupTestRollup(t)
Expand Down