From b617d0843efbb4ec4e7ed89d52f09dfdc549bb43 Mon Sep 17 00:00:00 2001 From: crazywriter1 Date: Mon, 31 Aug 2026 17:11:28 +0300 Subject: [PATCH] fix(tx-submitter): recover discarded rollup txs via rebuild instead of self-transfer --- tx-submitter/constants/methods.go | 5 +++ tx-submitter/constants/methods_test.go | 25 +++++++++++++ tx-submitter/services/rollup.go | 39 +++++++++++++++------ tx-submitter/services/rollup_handle_test.go | 38 ++++++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tx-submitter/constants/methods_test.go diff --git a/tx-submitter/constants/methods.go b/tx-submitter/constants/methods.go index 7de9eb498..1bbbfc7f6 100644 --- a/tx-submitter/constants/methods.go +++ b/tx-submitter/constants/methods.go @@ -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 +} diff --git a/tx-submitter/constants/methods_test.go b/tx-submitter/constants/methods_test.go new file mode 100644 index 000000000..13000cbd0 --- /dev/null +++ b/tx-submitter/constants/methods_test.go @@ -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) + } + }) + } +} diff --git a/tx-submitter/services/rollup.go b/tx-submitter/services/rollup.go index 6639fe2bb..dea4986d3 100644 --- a/tx-submitter/services/rollup.go +++ b/tx-submitter/services/rollup.go @@ -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) + } } } @@ -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") diff --git a/tx-submitter/services/rollup_handle_test.go b/tx-submitter/services/rollup_handle_test.go index ca9cb8db0..df48ffa41 100644 --- a/tx-submitter/services/rollup_handle_test.go +++ b/tx-submitter/services/rollup_handle_test.go @@ -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" @@ -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(ðtypes.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)