Skip to content

Commit 928e061

Browse files
authored
core/txpool: Take total rollup cost into account (L1 + operator fee) (#558)
* core/txpool: Take total rollup cost into account (L1 + operator fee) * add total rollup cost unit test * check optimism config * revert upstream formatting diffs * start testing txpool accounting with rollup costs * revert TestBlockRlpEncodeDecode to use OptimismCliqueTest config * fix txpool rollup cost accounting and add test * use total tx cost in list.Filter * properly handle nil pointer rollupCostFn * tweak test to use later-+1 * replace function ptr by interface * test rollup cost parameter changes
1 parent c3a989e commit 928e061

File tree

14 files changed

+445
-71
lines changed

14 files changed

+445
-71
lines changed

core/rlp_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func TestBlockRlpEncodeDecode(t *testing.T) {
204204
zeroTime := uint64(0)
205205

206206
// create a config where Isthmus upgrade is active
207-
config := *params.OptimismTestConfig
207+
config := *params.OptimismTestCliqueConfig
208208
config.ShanghaiTime = &zeroTime
209209
config.IsthmusTime = &zeroTime
210210
config.CancunTime = &zeroTime

core/txpool/legacypool/legacypool.go

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ type LegacyPool struct {
260260

261261
changesSinceReorg int // A counter for how many drops we've performed in-between reorg.
262262

263-
l1CostFn txpool.L1CostFunc // To apply L1 costs as rollup, optional field, may be nil.
263+
rollupCostFn txpool.RollupCostFunc // Additional rollup cost function, optional field, may be nil.
264264
}
265265

266266
type txpoolResetRequest struct {
@@ -330,6 +330,9 @@ func (pool *LegacyPool) Init(gasTip uint64, head *types.Header, reserve txpool.A
330330
pool.currentState = statedb
331331
pool.pendingNonces = newNoncer(statedb)
332332

333+
// OP-Stack addition
334+
pool.resetRollupCostFn(head.Time, statedb)
335+
333336
pool.wg.Add(1)
334337
go pool.scheduleReorgLoop()
335338

@@ -525,6 +528,10 @@ func (pool *LegacyPool) ToJournal() map[common.Address]types.Transactions {
525528
return txs
526529
}
527530

531+
func (pool *LegacyPool) RollupCostFunc() txpool.RollupCostFunc {
532+
return pool.rollupCostFn
533+
}
534+
528535
// Pending retrieves all currently processable transactions, grouped by origin
529536
// account and sorted by nonce.
530537
//
@@ -637,18 +644,15 @@ func (pool *LegacyPool) validateTx(tx *types.Transaction) error {
637644
ExistingCost: func(addr common.Address, nonce uint64) *big.Int {
638645
if list := pool.pending[addr]; list != nil {
639646
if tx := list.txs.Get(nonce); tx != nil {
640-
cost := tx.Cost()
641-
if pool.l1CostFn != nil {
642-
if l1Cost := pool.l1CostFn(tx.RollupCostData()); l1Cost != nil { // add rollup cost
643-
cost = cost.Add(cost, l1Cost)
644-
}
645-
}
646-
return cost
647+
// The total cost is guaranteed to not overflow because it got already
648+
// successfully added to the list.
649+
cost, _ := txpool.TotalTxCost(tx, pool.rollupCostFn)
650+
return cost.ToBig()
647651
}
648652
}
649653
return nil
650654
},
651-
L1CostFn: pool.l1CostFn,
655+
RollupCostFn: pool.rollupCostFn,
652656
}
653657
if err := txpool.ValidateTransactionWithState(tx, pool.signer, opts); err != nil {
654658
return err
@@ -802,7 +806,7 @@ func (pool *LegacyPool) add(tx *types.Transaction) (replaced bool, err error) {
802806
// Try to replace an existing transaction in the pending pool
803807
if list := pool.pending[from]; list != nil && list.Contains(tx.Nonce()) {
804808
// Nonce already pending, check if required price bump is met
805-
inserted, old := list.Add(tx, pool.config.PriceBump, pool.l1CostFn)
809+
inserted, old := list.Add(tx, pool.config.PriceBump)
806810
if !inserted {
807811
pendingDiscardMeter.Mark(1)
808812
return false, txpool.ErrReplaceUnderpriced
@@ -863,9 +867,9 @@ func (pool *LegacyPool) enqueueTx(hash common.Hash, tx *types.Transaction, addAl
863867
// Try to insert the transaction into the future queue
864868
from, _ := types.Sender(pool.signer, tx) // already validated
865869
if pool.queue[from] == nil {
866-
pool.queue[from] = newList(false)
870+
pool.queue[from] = newRollupList(false, pool)
867871
}
868-
inserted, old := pool.queue[from].Add(tx, pool.config.PriceBump, pool.l1CostFn)
872+
inserted, old := pool.queue[from].Add(tx, pool.config.PriceBump)
869873
if !inserted {
870874
// An older transaction was better, discard this
871875
queuedDiscardMeter.Mark(1)
@@ -903,11 +907,11 @@ func (pool *LegacyPool) enqueueTx(hash common.Hash, tx *types.Transaction, addAl
903907
func (pool *LegacyPool) promoteTx(addr common.Address, hash common.Hash, tx *types.Transaction) bool {
904908
// Try to insert the transaction into the pending queue
905909
if pool.pending[addr] == nil {
906-
pool.pending[addr] = newList(true)
910+
pool.pending[addr] = newRollupList(true, pool)
907911
}
908912
list := pool.pending[addr]
909913

910-
inserted, old := list.Add(tx, pool.config.PriceBump, pool.l1CostFn)
914+
inserted, old := list.Add(tx, pool.config.PriceBump)
911915
if !inserted {
912916
// An older transaction was better, discard this
913917
pool.all.Remove(hash)
@@ -1418,34 +1422,21 @@ func (pool *LegacyPool) reset(oldHead, newHead *types.Header) {
14181422
pool.currentState = statedb
14191423
pool.pendingNonces = newNoncer(statedb)
14201424

1421-
if costFn := types.NewL1CostFunc(pool.chainconfig, statedb); costFn != nil {
1422-
pool.l1CostFn = func(rollupCostData types.RollupCostData) *big.Int {
1423-
return costFn(rollupCostData, newHead.Time)
1424-
}
1425-
}
1425+
// OP-Stack addition
1426+
pool.resetRollupCostFn(newHead.Time, statedb)
14261427

14271428
// Inject any transactions discarded due to reorgs
14281429
log.Debug("Reinjecting stale transactions", "count", len(reinject))
14291430
core.SenderCacher().Recover(pool.signer, reinject)
14301431
pool.addTxsLocked(reinject)
14311432
}
14321433

1433-
// reduceBalanceByL1Cost returns the given balance, reduced by the L1Cost of the first transaction in list if applicable
1434-
// Other txs will get filtered out necessary.
1435-
func (pool *LegacyPool) reduceBalanceByL1Cost(list *list, balance *uint256.Int) *uint256.Int {
1436-
if !list.Empty() && pool.l1CostFn != nil {
1437-
el := list.txs.FirstElement()
1438-
if l1Cost := pool.l1CostFn(el.RollupCostData()); l1Cost != nil {
1439-
l1Cost256 := uint256.MustFromBig(l1Cost)
1440-
if l1Cost256.Cmp(balance) >= 0 {
1441-
// Avoid underflow
1442-
balance = uint256.NewInt(0)
1443-
} else {
1444-
balance = new(uint256.Int).Sub(balance, l1Cost256)
1445-
}
1434+
func (pool *LegacyPool) resetRollupCostFn(ts uint64, statedb *state.StateDB) {
1435+
if costFn := types.NewTotalRollupCostFunc(pool.chainconfig, statedb); costFn != nil {
1436+
pool.rollupCostFn = func(tx types.RollupTransaction) *uint256.Int {
1437+
return costFn(tx, ts)
14461438
}
14471439
}
1448-
return balance
14491440
}
14501441

14511442
// promoteExecutables moves transactions that have become processable from the
@@ -1469,7 +1460,6 @@ func (pool *LegacyPool) promoteExecutables(accounts []common.Address) []*types.T
14691460
}
14701461
log.Trace("Removed old queued transactions", "count", len(forwards))
14711462
balance := pool.currentState.GetBalance(addr)
1472-
balance = pool.reduceBalanceByL1Cost(list, balance)
14731463
// Drop all transactions that are too costly (low balance or out of gas)
14741464
drops, _ := list.Filter(balance, gasLimit)
14751465
for _, tx := range drops {
@@ -1659,7 +1649,6 @@ func (pool *LegacyPool) demoteUnexecutables() {
16591649
log.Trace("Removed old pending transaction", "hash", hash)
16601650
}
16611651
balance := pool.currentState.GetBalance(addr)
1662-
balance = pool.reduceBalanceByL1Cost(list, balance)
16631652
// Drop all transactions that are too costly (low balance or out of gas), and queue any invalids back for later
16641653
drops, invalids := list.Filter(balance, gasLimit)
16651654
for _, tx := range drops {
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright 2025 The op-geth Authors
2+
// This file is part of the op-geth library.
3+
//
4+
// The op-geth library is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// The op-geth library is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with the op-geth library. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package legacypool
18+
19+
import (
20+
"crypto/ecdsa"
21+
"math/big"
22+
"testing"
23+
24+
"github.com/ethereum/go-ethereum/common"
25+
"github.com/ethereum/go-ethereum/core"
26+
"github.com/ethereum/go-ethereum/core/txpool"
27+
"github.com/ethereum/go-ethereum/core/types"
28+
"github.com/ethereum/go-ethereum/params"
29+
"github.com/stretchr/testify/require"
30+
)
31+
32+
func setupOPStackPool() (*LegacyPool, *ecdsa.PrivateKey) {
33+
return setupPoolWithConfig(params.OptimismTestConfig)
34+
}
35+
36+
func setupTestL1FeeParams(t *testing.T, pool *LegacyPool) {
37+
l1FeeScalars := common.Hash{19: 1} // smallest possible base fee scalar
38+
// sanity check
39+
l1BaseFeeScalar, l1BlobBaseFeeScalar := types.ExtractEcotoneFeeParams(l1FeeScalars[:])
40+
require.EqualValues(t, 1, l1BaseFeeScalar.Uint64())
41+
require.Zero(t, l1BlobBaseFeeScalar.Sign())
42+
pool.currentState.SetState(types.L1BlockAddr, types.L1FeeScalarsSlot, l1FeeScalars)
43+
l1BaseFee := big.NewInt(1e6) // to account for division by 1e12 in L1 cost
44+
pool.currentState.SetState(types.L1BlockAddr, types.L1BaseFeeSlot, common.BigToHash(l1BaseFee))
45+
// sanity checks
46+
require.Equal(t, l1BaseFee, pool.currentState.GetState(types.L1BlockAddr, types.L1BaseFeeSlot).Big())
47+
}
48+
49+
func setupTestOperatorFeeParams(opFeeConst byte) func(t *testing.T, pool *LegacyPool) {
50+
return func(t *testing.T, pool *LegacyPool) {
51+
opFeeParams := common.Hash{31: opFeeConst} // 0 scalar
52+
// sanity check
53+
s, c := types.ExtractOperatorFeeParams(opFeeParams)
54+
require.Zero(t, s.Sign())
55+
require.EqualValues(t, opFeeConst, c.Uint64())
56+
pool.currentState.SetState(types.L1BlockAddr, types.OperatorFeeParamsSlot, opFeeParams)
57+
}
58+
}
59+
60+
func TestInvalidRollupTransactions(t *testing.T) {
61+
t.Run("zero-rollup-cost", func(t *testing.T) {
62+
testInvalidRollupTransactions(t, nil)
63+
})
64+
65+
t.Run("l1-cost", func(t *testing.T) {
66+
testInvalidRollupTransactions(t, setupTestL1FeeParams)
67+
})
68+
69+
t.Run("operator-cost", func(t *testing.T) {
70+
testInvalidRollupTransactions(t, setupTestOperatorFeeParams(1))
71+
})
72+
}
73+
74+
func testInvalidRollupTransactions(t *testing.T, stateMod func(t *testing.T, pool *LegacyPool)) {
75+
t.Parallel()
76+
77+
pool, key := setupOPStackPool()
78+
defer pool.Close()
79+
80+
const gasLimit = 100_000
81+
tx := transaction(0, gasLimit, key)
82+
from, _ := deriveSender(tx)
83+
84+
// base fee is 1
85+
testAddBalance(pool, from, new(big.Int).Add(big.NewInt(gasLimit), tx.Value()))
86+
// we add the test variant with zero rollup cost as a sanity check that the tx would indeed be valid
87+
if stateMod == nil {
88+
require.NoError(t, pool.addRemote(tx))
89+
return
90+
}
91+
92+
// Now we cause insufficient funds error due to rollup cost
93+
stateMod(t, pool)
94+
95+
rcost := pool.rollupCostFn(tx)
96+
require.Equal(t, 1, rcost.Sign(), "rollup cost must be >0")
97+
98+
require.ErrorIs(t, pool.addRemote(tx), core.ErrInsufficientFunds)
99+
}
100+
101+
func TestRollupTransactionCostAccounting(t *testing.T) {
102+
t.Run("zero-rollup-cost", func(t *testing.T) {
103+
testRollupTransactionCostAccounting(t, nil)
104+
})
105+
106+
t.Run("l1-cost", func(t *testing.T) {
107+
testRollupTransactionCostAccounting(t, setupTestL1FeeParams)
108+
})
109+
110+
t.Run("operator-cost", func(t *testing.T) {
111+
testRollupTransactionCostAccounting(t, setupTestOperatorFeeParams(1))
112+
})
113+
}
114+
115+
func testRollupTransactionCostAccounting(t *testing.T, stateMod func(t *testing.T, pool *LegacyPool)) {
116+
t.Parallel()
117+
118+
pool, key := setupOPStackPool()
119+
defer pool.Close()
120+
121+
const gasLimit = 100_000
122+
gasPrice0, gasPrice1 := big.NewInt(100), big.NewInt(110)
123+
tx0 := pricedTransaction(0, gasLimit, gasPrice0, key)
124+
tx1 := pricedTransaction(0, gasLimit, gasPrice1, key)
125+
from, _ := deriveSender(tx0)
126+
127+
require.NotNil(t, pool.rollupCostFn)
128+
129+
if stateMod != nil {
130+
stateMod(t, pool)
131+
}
132+
133+
cost0, of := txpool.TotalTxCost(tx0, pool.rollupCostFn)
134+
require.False(t, of)
135+
cost1, of := txpool.TotalTxCost(tx1, pool.rollupCostFn)
136+
require.False(t, of)
137+
138+
if stateMod != nil {
139+
require.Greater(t, cost0.Uint64(), tx0.Cost().Uint64(), "tx0 total cost should be greater than regular cost")
140+
}
141+
142+
// we add the initial tx to the pool
143+
testAddBalance(pool, from, cost1.ToBig()) // already give enough funds for tx1
144+
require.NoError(t, pool.addRemoteSync(tx0))
145+
_, ok := pool.queue[from]
146+
require.False(t, ok, "tx0 should not be in queue, but pending")
147+
pending, ok := pool.pending[from]
148+
require.True(t, ok, "tx0 should be pending")
149+
require.Equal(t, cost0, pending.totalcost, "tx0 total pending cost should match")
150+
151+
// now we add a replacement and check the accounting
152+
require.NoError(t, pool.addRemoteSync(tx1))
153+
_, ok = pool.queue[from]
154+
require.False(t, ok, "tx1 should not be in queue, but pending")
155+
pending, ok = pool.pending[from]
156+
require.True(t, ok, "tx1 should be pending")
157+
require.Equal(t, cost1, pending.totalcost, "tx1 total pending cost should match")
158+
}
159+
160+
// TestRollupCostFuncChange tests that changes in the underlying rollup cost parameters
161+
// are correctly picked up by the transaction pool and the underlying list implementation.
162+
func TestRollupCostFuncChange(t *testing.T) {
163+
t.Parallel()
164+
165+
pool, key := setupOPStackPool()
166+
defer pool.Close()
167+
168+
const gasLimit = 100_000
169+
gasPrice := big.NewInt(100)
170+
tx0 := pricedTransaction(0, gasLimit, gasPrice, key)
171+
tx1 := pricedTransaction(1, gasLimit, gasPrice, key)
172+
from, _ := deriveSender(tx0)
173+
174+
require.NotNil(t, pool.rollupCostFn)
175+
176+
setupTestOperatorFeeParams(10)(t, pool)
177+
178+
cost0, of := txpool.TotalTxCost(tx0, pool.rollupCostFn)
179+
require.False(t, of)
180+
181+
// 1st add tx0, consuming all balance
182+
testAddBalance(pool, from, cost0.ToBig())
183+
require.NoError(t, pool.addRemoteSync(tx0))
184+
185+
// 2nd add same balance but increase op fee const by 10
186+
// so adding 2nd tx should fail with 10 missing.
187+
testAddBalance(pool, from, cost0.ToBig())
188+
pool.reset(nil, nil) // reset the rollup cost function, simulates a head change
189+
setupTestOperatorFeeParams(20)(t, pool)
190+
require.ErrorContains(t, pool.addRemoteSync(tx1), "overshot 10")
191+
192+
// 3rd now add missing 10, adding tx1 should succeed
193+
testAddBalance(pool, from, big.NewInt(10))
194+
require.NoError(t, pool.addRemoteSync(tx1))
195+
}

0 commit comments

Comments
 (0)