From e8f20a52f12c74057ec778b6545eb60725135e24 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Fri, 13 Mar 2026 15:09:46 +0100 Subject: [PATCH] fix: support CeloDynamicFeeTxV2 (CIP-64) in span batches Span batch encoding/decoding failed on Celo CIP-64 transactions (type 0x7b) because the tx type switch statements only handled legacy, access list, dynamic fee, and set code types. Add spanBatchCeloDynamicFeeTxV2Data to carry the FeeCurrency field through the encode/decode round trip, and wire it into all 5 switch sites: newSpanBatchTx, decodeTyped, convertToFullTx, recoverV, and convertVToYParity. --- op-node/rollup/derive/span_batch_tx.go | 46 ++++++++++++++++++++ op-node/rollup/derive/span_batch_tx_test.go | 21 +++++---- op-node/rollup/derive/span_batch_txs.go | 4 ++ op-node/rollup/derive/span_batch_txs_test.go | 6 +++ op-service/testutils/random.go | 24 ++++++++++ 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/op-node/rollup/derive/span_batch_tx.go b/op-node/rollup/derive/span_batch_tx.go index 876fbd9347f27..e7bd15048d074 100644 --- a/op-node/rollup/derive/span_batch_tx.go +++ b/op-node/rollup/derive/span_batch_tx.go @@ -57,6 +57,19 @@ type spanBatchSetCodeTxData struct { func (txData *spanBatchSetCodeTxData) txType() byte { return types.SetCodeTxType } +type spanBatchCeloDynamicFeeTxV2Data struct { + Value *big.Int + GasTipCap *big.Int // a.k.a. maxPriorityFeePerGas + GasFeeCap *big.Int // a.k.a. maxFeePerGas + Data []byte + AccessList types.AccessList + FeeCurrency *common.Address `rlp:"nil"` +} + +func (txData *spanBatchCeloDynamicFeeTxV2Data) txType() byte { + return types.CeloDynamicFeeTxV2Type +} + // Type returns the transaction type. func (tx *spanBatchTx) Type() uint8 { return tx.inner.txType() @@ -112,6 +125,13 @@ func (tx *spanBatchTx) decodeTyped(b []byte) (spanBatchTxData, error) { return nil, fmt.Errorf("failed to decode spanBatchSetCodeTxData: %w", err) } return &inner, nil + case types.CeloDynamicFeeTxV2Type: + var inner spanBatchCeloDynamicFeeTxV2Data + err := rlp.DecodeBytes(b[1:], &inner) + if err != nil { + return nil, fmt.Errorf("failed to decode spanBatchCeloDynamicFeeTxV2Data: %w", err) + } + return &inner, nil default: return nil, types.ErrTxTypeNotSupported } @@ -208,6 +228,23 @@ func (tx *spanBatchTx) convertToFullTx(nonce, gas uint64, to *common.Address, ch R: uint256.MustFromBig(R), S: uint256.MustFromBig(S), } + case types.CeloDynamicFeeTxV2Type: + batchTxInner := tx.inner.(*spanBatchCeloDynamicFeeTxV2Data) + inner = &types.CeloDynamicFeeTxV2{ + ChainID: chainID, + Nonce: nonce, + GasTipCap: batchTxInner.GasTipCap, + GasFeeCap: batchTxInner.GasFeeCap, + Gas: gas, + To: to, + Value: batchTxInner.Value, + Data: batchTxInner.Data, + AccessList: batchTxInner.AccessList, + FeeCurrency: batchTxInner.FeeCurrency, + V: V, + R: R, + S: S, + } default: return nil, fmt.Errorf("invalid tx type: %d", tx.Type()) } @@ -248,6 +285,15 @@ func newSpanBatchTx(tx *types.Transaction) (*spanBatchTx, error) { AccessList: tx.AccessList(), AuthorizationList: tx.SetCodeAuthorizations(), } + case types.CeloDynamicFeeTxV2Type: + inner = &spanBatchCeloDynamicFeeTxV2Data{ + GasTipCap: tx.GasTipCap(), + GasFeeCap: tx.GasFeeCap(), + Value: tx.Value(), + Data: tx.Data(), + AccessList: tx.AccessList(), + FeeCurrency: tx.FeeCurrency(), + } default: return nil, fmt.Errorf("invalid tx type: %d", tx.Type()) } diff --git a/op-node/rollup/derive/span_batch_tx_test.go b/op-node/rollup/derive/span_batch_tx_test.go index d215a0f2c6be0..ed4ea683f01a1 100644 --- a/op-node/rollup/derive/span_batch_tx_test.go +++ b/op-node/rollup/derive/span_batch_tx_test.go @@ -18,6 +18,15 @@ type spanBatchTxTest struct { protected bool } +// spanBatchTestSigner returns a signer that supports all tx types including +// Celo CIP-64. LatestSignerForChainID wraps with the Celo signer overlay. +func spanBatchTestSigner(chainID *big.Int, protected bool) types.Signer { + if !protected { + return types.HomesteadSigner{} + } + return types.LatestSignerForChainID(chainID) +} + func TestSpanBatchTxConvert(t *testing.T) { cases := []spanBatchTxTest{ {"unprotected legacy tx", 32, testutils.RandomLegacyTx, false}, @@ -25,16 +34,14 @@ func TestSpanBatchTxConvert(t *testing.T) { {"access list tx", 32, testutils.RandomAccessListTx, true}, {"dynamic fee tx", 32, testutils.RandomDynamicFeeTx, true}, {"setcode tx", 32, testutils.RandomSetCodeTx, true}, + {"celo dynamic fee tx v2", 32, testutils.RandomCeloDynamicFeeTxV2, true}, } for i, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { rng := rand.New(rand.NewSource(int64(0x1331 + i))) chainID := big.NewInt(rng.Int63n(1000)) - signer := types.NewIsthmusSigner(chainID) - if !testCase.protected { - signer = types.HomesteadSigner{} - } + signer := spanBatchTestSigner(chainID, testCase.protected) for txIdx := 0; txIdx < testCase.trials; txIdx++ { tx := testCase.mkTx(rng, signer) @@ -65,16 +72,14 @@ func TestSpanBatchTxRoundTrip(t *testing.T) { {"access list tx", 32, testutils.RandomAccessListTx, true}, {"dynamic fee tx", 32, testutils.RandomDynamicFeeTx, true}, {"setcode tx", 32, testutils.RandomSetCodeTx, true}, + {"celo dynamic fee tx v2", 32, testutils.RandomCeloDynamicFeeTxV2, true}, } for i, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { rng := rand.New(rand.NewSource(int64(0x1332 + i))) chainID := big.NewInt(rng.Int63n(1000)) - signer := types.NewIsthmusSigner(chainID) - if !testCase.protected { - signer = types.HomesteadSigner{} - } + signer := spanBatchTestSigner(chainID, testCase.protected) for txIdx := 0; txIdx < testCase.trials; txIdx++ { tx := testCase.mkTx(rng, signer) diff --git a/op-node/rollup/derive/span_batch_txs.go b/op-node/rollup/derive/span_batch_txs.go index 7c354430fc30e..1b6dd3bfff420 100644 --- a/op-node/rollup/derive/span_batch_txs.go +++ b/op-node/rollup/derive/span_batch_txs.go @@ -273,6 +273,8 @@ func (btx *spanBatchTxs) recoverV(chainID *big.Int) error { v = bit case types.SetCodeTxType: v = bit + case types.CeloDynamicFeeTxV2Type: + v = bit default: return fmt.Errorf("invalid tx type: %d", txType) } @@ -390,6 +392,8 @@ func convertVToYParity(v uint64, txType int) (uint, error) { yParityBit = uint(v) case types.SetCodeTxType: yParityBit = uint(v) + case types.CeloDynamicFeeTxV2Type: + yParityBit = uint(v) default: return 0, fmt.Errorf("invalid tx type: %d", txType) } diff --git a/op-node/rollup/derive/span_batch_txs_test.go b/op-node/rollup/derive/span_batch_txs_test.go index 1a752f75f47ba..99d6d485a27f1 100644 --- a/op-node/rollup/derive/span_batch_txs_test.go +++ b/op-node/rollup/derive/span_batch_txs_test.go @@ -334,6 +334,7 @@ func TestSpanBatchTxsRecoverV(t *testing.T) { chainID := big.NewInt(rng.Int63n(1000)) isthmusSigner := types.NewIsthmusSigner(chainID) + celoSigner := types.LatestSignerForChainID(chainID) totalblockTxCount := 20 + rng.Intn(100) cases := []txTypeTest{ @@ -342,6 +343,7 @@ func TestSpanBatchTxsRecoverV(t *testing.T) { {"access list tx", testutils.RandomAccessListTx, isthmusSigner}, {"dynamic fee tx", testutils.RandomDynamicFeeTx, isthmusSigner}, {"setcode tx", testutils.RandomSetCodeTx, isthmusSigner}, + {"celo dynamic fee tx v2", testutils.RandomCeloDynamicFeeTxV2, celoSigner}, } for _, testCase := range cases { @@ -426,6 +428,7 @@ func TestSpanBatchTxsRoundTripFullTxs(t *testing.T) { rng := rand.New(rand.NewSource(0x13377331)) chainID := big.NewInt(rng.Int63n(1000)) isthmusSigner := types.NewIsthmusSigner(chainID) + celoSigner := types.LatestSignerForChainID(chainID) cases := []txTypeTest{ {"unprotected legacy tx", testutils.RandomLegacyTx, types.HomesteadSigner{}}, @@ -433,6 +436,7 @@ func TestSpanBatchTxsRoundTripFullTxs(t *testing.T) { {"access list tx", testutils.RandomAccessListTx, isthmusSigner}, {"dynamic fee tx", testutils.RandomDynamicFeeTx, isthmusSigner}, {"setcode tx", testutils.RandomSetCodeTx, isthmusSigner}, + {"celo dynamic fee tx v2", testutils.RandomCeloDynamicFeeTxV2, celoSigner}, } for _, testCase := range cases { @@ -477,6 +481,7 @@ func TestSpanBatchTxsFullTxNotEnoughTxTos(t *testing.T) { rng := rand.New(rand.NewSource(0x13572468)) chainID := big.NewInt(rng.Int63n(1000)) isthmusSigner := types.NewIsthmusSigner(chainID) + celoSigner := types.LatestSignerForChainID(chainID) cases := []txTypeTest{ {"unprotected legacy tx", testutils.RandomLegacyTx, types.HomesteadSigner{}}, @@ -484,6 +489,7 @@ func TestSpanBatchTxsFullTxNotEnoughTxTos(t *testing.T) { {"access list tx", testutils.RandomAccessListTx, isthmusSigner}, {"dynamic fee tx", testutils.RandomDynamicFeeTx, isthmusSigner}, {"setcode tx", testutils.RandomSetCodeTx, isthmusSigner}, + {"celo dynamic fee tx v2", testutils.RandomCeloDynamicFeeTxV2, celoSigner}, } for _, testCase := range cases { diff --git a/op-service/testutils/random.go b/op-service/testutils/random.go index e685b97c11b39..7187ea1c1033b 100644 --- a/op-service/testutils/random.go +++ b/op-service/testutils/random.go @@ -296,6 +296,30 @@ func RandomSetCodeTx(rng *rand.Rand, signer types.Signer) *types.Transaction { return tx } +func RandomCeloDynamicFeeTxV2(rng *rand.Rand, signer types.Signer) *types.Transaction { + baseFee := new(big.Int).SetUint64(rng.Uint64()) + key := InsecureRandomKey(rng) + tip := big.NewInt(rng.Int63n(10 * params.GWei)) + feeCurrency := RandomAddress(rng) + txData := &types.CeloDynamicFeeTxV2{ + ChainID: signer.ChainID(), + Nonce: rng.Uint64(), + GasTipCap: tip, + GasFeeCap: new(big.Int).Add(baseFee, tip), + Gas: params.TxGas + uint64(rng.Int63n(2_000_000)), + To: RandomTo(rng), + Value: RandomETH(rng, 10), + Data: RandomData(rng, rng.Intn(RandomDataSize)), + AccessList: nil, + FeeCurrency: &feeCurrency, + } + tx, err := types.SignNewTx(key, signer, txData) + if err != nil { + panic(err) + } + return tx +} + func RandomReceipt(rng *rand.Rand, signer types.Signer, tx *types.Transaction, txIndex uint64, cumulativeGasUsed uint64) *types.Receipt { gasUsed := params.TxGas + uint64(rng.Int63n(int64(tx.Gas()-params.TxGas+1))) logs := make([]*types.Log, rng.Intn(10))