From 0d13437991d529a79c6c229029a4d0b962ee67d0 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Mon, 12 Jan 2026 19:51:43 +0100 Subject: [PATCH 01/16] core/txpool/blobpool: add test for drop at capacity Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool_test.go | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index 4bb3567b69..dcb14efbc7 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -1359,9 +1359,10 @@ func TestAdd(t *testing.T) { } tests := []struct { - seeds map[string]seed - adds []addtx - block []addtx + seeds map[string]seed + adds []addtx + block []addtx + datacap uint64 }{ // Transactions from new accounts should be accepted if their initial // nonce matches the expected one from the statedb. Higher or lower must @@ -1715,6 +1716,24 @@ func TestAdd(t *testing.T) { }, }, }, + // Transactions above the Datacap should be rejected + { + seeds: map[string]seed{ + "alice": {balance: 10000000}, + }, + datacap: 1 * (txAvgSize + blobSize + uint64(txBlobOverhead)), // only allow 1 blob + adds: []addtx{ + { // Fits in capacity + from: "alice", + tx: makeUnsignedTx(0, 1, 1, 1), + }, + { // Beyond capacity + from: "alice", + tx: makeUnsignedTx(1, 1, 1, 1), + err: txpool.ErrUnderpriced, + }, + }, + }, } for i, tt := range tests { // Create a temporary folder for the persistent backend @@ -1755,7 +1774,7 @@ func TestAdd(t *testing.T) { blobfee: uint256.NewInt(105), statedb: statedb, } - pool := New(Config{Datadir: storage}, chain, nil) + pool := New(Config{Datadir: storage, Datacap: tt.datacap}, chain, nil) if err := pool.Init(1, chain.CurrentBlock(), newReserver()); err != nil { t.Fatalf("test %d: failed to create blob pool: %v", i, err) } From e9d185f15b15329c3dc268d5e1e71ed8225efbdd Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Mon, 12 Jan 2026 15:50:56 +0100 Subject: [PATCH 02/16] core/txpool/blobpool: return error on underpriced This also solves a forwarding issue, where underpriced transactions were announced. Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 27441ac2e2..a1d9d9b820 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1676,6 +1676,14 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error } p.updateStorageMetrics() + // If we've just dropped the added transaction, it was clearly underpriced. + // We could also try to check for this earlier, but it is compex because + // of the rolling fee caculations. + if !p.lookup.exists(tx.Hash()) { + addUnderpricedMeter.Mark(1) + return txpool.ErrUnderpriced + } + addValidMeter.Mark(1) // Notify all listeners of the new arrival From 5c1272effc2ccd9ccf75e35f02d8e8f35518ddb7 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Mon, 12 Jan 2026 20:02:52 +0100 Subject: [PATCH 03/16] core/txpool/blobpool: verify that events are emittted correctly Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index dcb14efbc7..d50dfee2cb 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -1780,6 +1780,11 @@ func TestAdd(t *testing.T) { } verifyPoolInternals(t, pool) + // subscibe to pool events to verify they are emitted correctly + txsCh := make(chan core.NewTxsEvent, 1) + txsSub := pool.SubscribeTransactions(txsCh, false) + defer txsSub.Unsubscribe() + // Add each transaction one by one, verifying the pool internals in between for j, add := range tt.adds { signed, _ := types.SignNewTx(keys[add.from], types.LatestSigner(params.MainnetChainConfig), add.tx) @@ -1810,6 +1815,26 @@ func TestAdd(t *testing.T) { } // Verify the pool internals after each addition verifyPoolInternals(t, pool) + // verify that if the tx was added, an event was emitted + if add.err == nil { + select { + case ev := <-txsCh: + if len(ev.Txs) != 1 { + t.Errorf("test %d, tx %d: event txs length mismatch: have %d, want 1", i, j, len(ev.Txs)) + } + if ev.Txs[0].Hash() != signed.Hash() { + t.Errorf("test %d, tx %d: event tx mismatch: have %v, want %v", i, j, ev.Txs[0].Hash(), signed.Hash()) + } + default: + t.Errorf("test %d, tx %d: expected new tx event, none received", i, j) + } + } else { + select { + case ev := <-txsCh: + t.Errorf("test %d, tx %d: unexpected new tx event with %d txs", i, j, len(ev.Txs)) + default: + } + } } verifyPoolInternals(t, pool) From daa495f7759b03e64b84356997995f5b57292cec Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Fri, 16 Jan 2026 11:35:27 +0100 Subject: [PATCH 04/16] core/txpool: fix check for gapped messages Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool_test.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index d50dfee2cb..97bbe9a5ce 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -1816,23 +1816,30 @@ func TestAdd(t *testing.T) { // Verify the pool internals after each addition verifyPoolInternals(t, pool) // verify that if the tx was added, an event was emitted - if add.err == nil { - select { - case ev := <-txsCh: + txStatus := pool.Status(signed.Hash()) + select { + case ev := <-txsCh: + switch { + case add.err == nil && txStatus == txpool.TxStatusPending: if len(ev.Txs) != 1 { t.Errorf("test %d, tx %d: event txs length mismatch: have %d, want 1", i, j, len(ev.Txs)) } if ev.Txs[0].Hash() != signed.Hash() { t.Errorf("test %d, tx %d: event tx mismatch: have %v, want %v", i, j, ev.Txs[0].Hash(), signed.Hash()) } + case add.err == nil && txStatus == txpool.TxStatusQueued: + t.Errorf("test %d, tx %d: unexpected new tx event for queued tx", i, j) + case add.err != nil: + t.Errorf("test %d, tx %d: unexpected new tx event for failed tx", i, j) default: - t.Errorf("test %d, tx %d: expected new tx event, none received", i, j) + t.Errorf("test %d, tx %d: unexpected test result", i, j) } - } else { - select { - case ev := <-txsCh: - t.Errorf("test %d, tx %d: unexpected new tx event with %d txs", i, j, len(ev.Txs)) + default: + switch { + case add.err == nil && txStatus == txpool.TxStatusPending: + t.Errorf("test %d, tx %d: expected new tx event, none received", i, j) default: + // expected no event } } } From 32a7c2b324eb1dc309e52c5a062f30d173824bee Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Fri, 30 Jan 2026 12:31:27 +0100 Subject: [PATCH 05/16] core/txpool/blobpool: drop underpriced before adding to pool Underpriced transactions were first added then dropped. This created various issues: - dropping is neither FIFO nor LIFO in a heap, so we had undefined behavior between equal priced transactions. We now only add a new transaction if it is strictly better than the worst in the pool. - adding and removing created extra disk writes - adding resulted in sending announcements to peers Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 101 ++++++++++++++++++++++-------- core/txpool/blobpool/evictheap.go | 20 ++++++ 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index a1d9d9b820..a44da3952b 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -135,28 +135,38 @@ type blobTxMeta struct { evictionBlobFeeJumps float64 // Worse blob fee (converted to fee jumps) across all previous nonces } +// newStoredBlobTxMeta retrieves the indexed metadata fields from a blob transaction, +// adds storage specific fields (ID and size), and assembles a helper struct to track in memory. +// Requires the transaction to have a sidecar (or that we introduce a special version tag for no-sidecar). +func newStoredBlobTxMeta(tx *types.Transaction, id uint64, storageSize uint32) *blobTxMeta { + meta := newBlobTxMeta(tx) + meta.id = id + meta.storageSize = storageSize + return meta +} + // newBlobTxMeta retrieves the indexed metadata fields from a blob transaction // and assembles a helper struct to track in memory. -// Requires the transaction to have a sidecar (or that we introduce a special version tag for no-sidecar). -func newBlobTxMeta(id uint64, size uint64, storageSize uint32, tx *types.Transaction) *blobTxMeta { +// newBlobTxMeta leaves storage specific fields empty. Use newStoredBlobTxMeta +// to also populate those. +// Requires the transaction to have a sidecar. +func newBlobTxMeta(tx *types.Transaction) *blobTxMeta { if tx.BlobTxSidecar() == nil { // This should never happen, as the pool only admits blob transactions with a sidecar panic("missing blob tx sidecar") } meta := &blobTxMeta{ - hash: tx.Hash(), - vhashes: tx.BlobHashes(), - version: tx.BlobTxSidecar().Version, - id: id, - storageSize: storageSize, - size: size, - nonce: tx.Nonce(), - costCap: uint256.MustFromBig(tx.Cost()), - execTipCap: uint256.MustFromBig(tx.GasTipCap()), - execFeeCap: uint256.MustFromBig(tx.GasFeeCap()), - blobFeeCap: uint256.MustFromBig(tx.BlobGasFeeCap()), - execGas: tx.Gas(), - blobGas: tx.BlobGas(), + hash: tx.Hash(), + vhashes: tx.BlobHashes(), + version: tx.BlobTxSidecar().Version, + size: tx.Size(), + nonce: tx.Nonce(), + costCap: uint256.MustFromBig(tx.Cost()), + execTipCap: uint256.MustFromBig(tx.GasTipCap()), + execFeeCap: uint256.MustFromBig(tx.GasFeeCap()), + blobFeeCap: uint256.MustFromBig(tx.BlobGasFeeCap()), + execGas: tx.Gas(), + blobGas: tx.BlobGas(), } meta.basefeeJumps = dynamicFeeJumps(meta.execFeeCap) meta.blobfeeJumps = dynamicFeeJumps(meta.blobFeeCap) @@ -531,7 +541,7 @@ func (p *BlobPool) parseTransaction(id uint64, size uint32, blob []byte) error { return errors.New("missing blob sidecar") } - meta := newBlobTxMeta(id, tx.Size(), size, tx) + meta := newStoredBlobTxMeta(tx, id, size) if p.lookup.exists(meta.hash) { // This path is only possible after a crash, where deleted items are not // removed via the normal shutdown-startup procedure and thus may get @@ -1071,7 +1081,7 @@ func (p *BlobPool) reinject(addr common.Address, txhash common.Hash) error { } // Update the indices and metrics - meta := newBlobTxMeta(id, tx.Size(), p.store.Size(id), tx) + meta := newStoredBlobTxMeta(tx, id, p.store.Size(id)) if _, ok := p.index[addr]; !ok { if err := p.reserver.Hold(addr); err != nil { log.Warn("Failed to reserve account for blob pool", "tx", tx.Hash(), "from", addr, "err", err) @@ -1248,6 +1258,7 @@ func (p *BlobPool) validateTx(tx *types.Transaction) error { if err := p.checkDelegationLimit(tx); err != nil { return err } + // If the transaction replaces an existing one, ensure that price bumps are // adhered to. var ( @@ -1551,9 +1562,47 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error } return err } + + // Create meta, in preparation of adding to the pool. + // Having the meta simplifies the check below for underpriced transactions. + meta := newBlobTxMeta(tx) + + // Calculate the eviction parameters for the transaction + var ( + from, _ = types.Sender(p.signer, tx) // already validated above + next = p.state.GetNonce(from) + offset = int(meta.nonce - next) + ) + meta.evictionExecTip = meta.execTipCap + meta.evictionExecFeeJumps = meta.basefeeJumps + meta.evictionBlobFeeJumps = meta.blobfeeJumps + if meta.nonce > next && len(p.index[from]) >= offset { + prev := p.index[from][int(meta.nonce-next-1)] + if meta.evictionExecTip.Cmp(meta.execTipCap) < 0 { + meta.evictionExecTip = prev.evictionExecTip + } + if meta.evictionExecFeeJumps < meta.basefeeJumps { + meta.evictionExecFeeJumps = prev.evictionExecFeeJumps + } + if meta.evictionBlobFeeJumps < meta.blobfeeJumps { + meta.evictionBlobFeeJumps = prev.evictionBlobFeeJumps + } + } + + // Check pool size limits before inserting the transaction + // If at limit, check whether it is underpriced. + // Note: we do not have the exact storage size yet, so we try to guess + // Note: equal priority to worse of pool is still considered underpriced. + // This is to prevent constant replacement when the pool is full. + if p.stored+meta.size > p.config.Datacap { + if p.evict.Underpriced(meta) { + log.Warn("Dropping underpriced blob transaction", "tx", tx.Hash(), "feecap", tx.GasFeeCap(), "tipcap", tx.GasTipCap(), "blobfeecap", tx.BlobGasFeeCap()) + return txpool.ErrUnderpriced + } + } + // If the address is not yet known, request exclusivity to track the account // only by this subpool until all transactions are evicted - from, _ := types.Sender(p.signer, tx) // already validated above if _, ok := p.index[from]; !ok { if err := p.reserver.Hold(from); err != nil { addNonExclusiveMeter.Mark(1) @@ -1582,14 +1631,15 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error if err != nil { return err } - meta := newBlobTxMeta(id, tx.Size(), p.store.Size(id), tx) + // Finalize the meta with storage information + meta.id = id + meta.storageSize = p.store.Size(id) var ( - next = p.state.GetNonce(from) - offset = int(tx.Nonce() - next) - newacc = false + newacc = false + oldEvictionExecFeeJumps float64 + oldEvictionBlobFeeJumps float64 ) - var oldEvictionExecFeeJumps, oldEvictionBlobFeeJumps float64 if txs, ok := p.index[from]; ok { oldEvictionExecFeeJumps = txs[len(txs)-1].evictionExecFeeJumps oldEvictionBlobFeeJumps = txs[len(txs)-1].evictionBlobFeeJumps @@ -1677,9 +1727,10 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error p.updateStorageMetrics() // If we've just dropped the added transaction, it was clearly underpriced. - // We could also try to check for this earlier, but it is compex because - // of the rolling fee caculations. + // We've already checked for this with approximate size, but do a final + // check in case it was dropped with the exact size. if !p.lookup.exists(tx.Hash()) { + log.Warn("Added blob transaction was dropped immediately, indicating underpricing", "hash", tx.Hash()) addUnderpricedMeter.Mark(1) return txpool.ErrUnderpriced } diff --git a/core/txpool/blobpool/evictheap.go b/core/txpool/blobpool/evictheap.go index 722a71bc9b..0a2aedad83 100644 --- a/core/txpool/blobpool/evictheap.go +++ b/core/txpool/blobpool/evictheap.go @@ -94,6 +94,12 @@ func (h *evictHeap) Less(i, j int) bool { lastI := txsI[len(txsI)-1] lastJ := txsJ[len(txsJ)-1] + return h.txPrioLt(lastI, lastJ) +} + +// LessTx compares two blobTxMeta entries and returns whether the first has a lower +// eviction priority than the second. +func (h *evictHeap) txPrioLt(lastI, lastJ *blobTxMeta) bool { prioI := evictionPriority(h.basefeeJumps, lastI.evictionExecFeeJumps, h.blobfeeJumps, lastI.evictionBlobFeeJumps) if prioI > 0 { prioI = 0 @@ -123,6 +129,20 @@ func (h *evictHeap) Push(x any) { h.addrs = append(h.addrs, x.(common.Address)) } +// Underpriced checks whether the given transaction is underpriced compared to the +// cheapest transaction in the heap. +// If a transaction has the same priority as the cheapest, it is still considered +// underpriced. +func (h *evictHeap) Underpriced(meta *blobTxMeta) bool { + if len(h.addrs) == 0 { + return false + } + cheapestTxs := h.metas[h.addrs[0]] + cheapestTx := cheapestTxs[len(cheapestTxs)-1] + + return !h.txPrioLt(cheapestTx, meta) +} + // Pop implements heap.Interface, removing and returning the last element of the // heap. // From a4de295d5d6bdcc44b76c8df8d4325cd65c889f6 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Tue, 24 Feb 2026 11:59:17 +0100 Subject: [PATCH 06/16] fix loglevel Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index a44da3952b..a0188f4f16 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1596,7 +1596,7 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error // This is to prevent constant replacement when the pool is full. if p.stored+meta.size > p.config.Datacap { if p.evict.Underpriced(meta) { - log.Warn("Dropping underpriced blob transaction", "tx", tx.Hash(), "feecap", tx.GasFeeCap(), "tipcap", tx.GasTipCap(), "blobfeecap", tx.BlobGasFeeCap()) + log.Trace("Dropping underpriced blob transaction", "tx", tx.Hash(), "feecap", tx.GasFeeCap(), "tipcap", tx.GasTipCap(), "blobfeecap", tx.BlobGasFeeCap()) return txpool.ErrUnderpriced } } @@ -1730,7 +1730,7 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error // We've already checked for this with approximate size, but do a final // check in case it was dropped with the exact size. if !p.lookup.exists(tx.Hash()) { - log.Warn("Added blob transaction was dropped immediately, indicating underpricing", "hash", tx.Hash()) + log.Trace("Added blob transaction was dropped immediately, indicating underpricing", "hash", tx.Hash()) addUnderpricedMeter.Mark(1) return txpool.ErrUnderpriced } From dbf9235c10c8ccc6bdd5061eb129696a36af5172 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Tue, 24 Feb 2026 12:00:00 +0100 Subject: [PATCH 07/16] core/txpool/blobpool: add extra underpriced test Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool_test.go | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index 97bbe9a5ce..642fbb5102 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -1734,6 +1734,48 @@ func TestAdd(t *testing.T) { }, }, }, + // Transactions above the Datacap should be rejected, use eviction values + { + seeds: map[string]seed{ + "alice": { + balance: 2000000, + //nonce: 1, + txs: []*types.BlobTx{ + makeUnsignedTxWithTestBlob(0, 2, 2, 2, 0), + makeUnsignedTxWithTestBlob(1, 2, 2, 2, 1), + }, + }, + "bob": { + balance: 1000000, + //nonce: 1, + txs: []*types.BlobTx{ + makeUnsignedTxWithTestBlob(0, 3, 3, 3, 2), + }, + }, + }, + datacap: 3 * (txAvgSize + blobSize + uint64(txBlobOverhead)), // only allow 2 blobs + adds: []addtx{ + { // Beyond capacity, but kicking out one from Alice should make it fit + from: "bob", + tx: makeUnsignedTxWithTestBlob(1, 3, 3, 3, 3), + }, + { // We've just kicked our nonce 1, so this is nonce too high + from: "alice", + tx: makeUnsignedTxWithTestBlob(2, 1, 2, 2, 4), + err: core.ErrNonceTooHigh, + }, + { // This should not succeed, fees are low + from: "alice", + tx: makeUnsignedTxWithTestBlob(1, 1, 1, 1, 4), + err: txpool.ErrUnderpriced, + }, + { // This should also not succeed, because of rolling fee calculation + from: "alice", + tx: makeUnsignedTxWithTestBlob(1, 1, 1, 1, 4), + err: txpool.ErrUnderpriced, + }, + }, + }, } for i, tt := range tests { // Create a temporary folder for the persistent backend From 67ee431474aaaadd35b6e2000a05ac57cba70434 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Tue, 24 Feb 2026 12:00:38 +0100 Subject: [PATCH 08/16] core/txpool/blobpool: fix price comparison logic Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index a0188f4f16..95239cc582 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1578,13 +1578,13 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error meta.evictionBlobFeeJumps = meta.blobfeeJumps if meta.nonce > next && len(p.index[from]) >= offset { prev := p.index[from][int(meta.nonce-next-1)] - if meta.evictionExecTip.Cmp(meta.execTipCap) < 0 { + if meta.evictionExecTip.Cmp(prev.evictionExecTip) > 0 { meta.evictionExecTip = prev.evictionExecTip } - if meta.evictionExecFeeJumps < meta.basefeeJumps { + if meta.evictionExecFeeJumps > prev.evictionExecFeeJumps { meta.evictionExecFeeJumps = prev.evictionExecFeeJumps } - if meta.evictionBlobFeeJumps < meta.blobfeeJumps { + if meta.evictionBlobFeeJumps > prev.evictionBlobFeeJumps { meta.evictionBlobFeeJumps = prev.evictionBlobFeeJumps } } From 4d532c2edcaab29f4677aed48c2e7df836ddb15f Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Mon, 2 Mar 2026 13:51:16 +0100 Subject: [PATCH 09/16] core/txpool/blobpool: simplify rolling eviction field calculation Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 95239cc582..dc740719bb 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1576,7 +1576,7 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error meta.evictionExecTip = meta.execTipCap meta.evictionExecFeeJumps = meta.basefeeJumps meta.evictionBlobFeeJumps = meta.blobfeeJumps - if meta.nonce > next && len(p.index[from]) >= offset { + if meta.nonce > next { // transaction can't be gapped, we filter for that in validateTx prev := p.index[from][int(meta.nonce-next-1)] if meta.evictionExecTip.Cmp(prev.evictionExecTip) > 0 { meta.evictionExecTip = prev.evictionExecTip @@ -1672,20 +1672,13 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error p.lookup.track(meta) p.stored += uint64(meta.storageSize) } - // Recompute the rolling eviction fields. In case of a replacement, this will - // recompute all subsequent fields. In case of an append, this will only do - // the fresh calculation. + // Recompute the rolling eviction fields for subsequent transactions + // (we've already calculated for the new/updated transaction above). + // In case of a replacement, this will recompute all subsequent fields. + // In case of an append, this will only do the fresh calculation. txs := p.index[from] - for i := offset; i < len(txs); i++ { - // The first transaction will always use itself - if i == 0 { - txs[0].evictionExecTip = txs[0].execTipCap - txs[0].evictionExecFeeJumps = txs[0].basefeeJumps - txs[0].evictionBlobFeeJumps = txs[0].blobfeeJumps - - continue - } + for i := offset + 1; i < len(txs); i++ { // Subsequent transactions will use a rolling calculation txs[i].evictionExecTip = txs[i-1].evictionExecTip if txs[i].evictionExecTip.Cmp(txs[i].execTipCap) > 0 { From e273770c675cfb930e878bfef6fcc337694a5e43 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Tue, 3 Mar 2026 11:05:06 +0100 Subject: [PATCH 10/16] core/txpool/blobpool: expose storage sizes Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 13 +++++++------ core/txpool/blobpool/slotter.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index dc740719bb..cda293bce0 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -346,9 +346,10 @@ type BlobPool struct { reserver txpool.Reserver // Address reserver to ensure exclusivity across subpools hasPendingAuth func(common.Address) bool // Determine whether the specified address has a pending 7702-auth - store billy.Database // Persistent data store for the tx metadata and blobs - stored uint64 // Useful data size of all transactions on disk - limbo *limbo // Persistent data store for the non-finalized blobs + store billy.Database // Persistent data store for the tx metadata and blobs + slotter billy.SlotSizeFn // Slotter function to determine the shelf sizes for the billy database + stored uint64 // Useful data size of all transactions on disk + limbo *limbo // Persistent data store for the non-finalized blobs gapped map[common.Address][]*types.Transaction // Transactions that are currently gapped (nonce too high) gappedSource map[common.Hash]common.Address // Source of gapped transactions to allow rechecking on inclusion @@ -435,10 +436,10 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser p.state = state // Create new slotter for pre-Osaka blob configuration. - slotter := newSlotter(params.BlobTxMaxBlobs) + p.slotter = newSlotter(params.BlobTxMaxBlobs) // See if we need to migrate the queue blob store after fusaka - slotter, err = tryMigrate(p.chain.Config(), slotter, queuedir) + p.slotter, err = tryMigrate(p.chain.Config(), p.slotter, queuedir) if err != nil { return err } @@ -449,7 +450,7 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser fails = append(fails, id) } } - store, err := billy.Open(billy.Options{Path: queuedir, Repair: true}, slotter, index) + store, err := billy.Open(billy.Options{Path: queuedir, Repair: true}, p.slotter, index) if err != nil { return err } diff --git a/core/txpool/blobpool/slotter.go b/core/txpool/blobpool/slotter.go index 3399361e55..26e3116657 100644 --- a/core/txpool/blobpool/slotter.go +++ b/core/txpool/blobpool/slotter.go @@ -17,10 +17,25 @@ package blobpool import ( + "errors" + "github.com/ethereum/go-ethereum/params" "github.com/holiman/billy" ) +// getSlotSize return the storage size for a given transaction size based on the current slotter. +func getSlotSize(slotter billy.SlotSizeFn, size uint32) (uint32, error) { + for { + slotSize, done := slotter() + if size <= slotSize { + return slotSize, nil + } + if done { + return size, errors.New("size exceeds maximum slot size") + } + } +} + // tryMigrate checks if the billy needs to be migrated and migrates if needed. // Returns a slotter that can be used for the database. func tryMigrate(config *params.ChainConfig, slotter billy.SlotSizeFn, datadir string) (billy.SlotSizeFn, error) { From 0f5b89d765b1224cd849d129297ebcfba3dbf5b3 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Tue, 3 Mar 2026 11:08:21 +0100 Subject: [PATCH 11/16] core/txpool/blobpool: use correct storage size in undeprice check Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index cda293bce0..7d6534ca03 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1592,10 +1592,20 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error // Check pool size limits before inserting the transaction // If at limit, check whether it is underpriced. - // Note: we do not have the exact storage size yet, so we try to guess - // Note: equal priority to worse of pool is still considered underpriced. - // This is to prevent constant replacement when the pool is full. - if p.stored+meta.size > p.config.Datacap { + // Note: equal priority as the current worse of the pool is still considered + // underpriced. This is to prevent constant replacement when the pool is full. + storageSizeDiff, err := getSlotSize(p.slotter, uint32(meta.size)) + if err != nil { + // This should nver happen, but better safe than sorry. + log.Warn("Dropping blob transaction due to size", "tx", tx.Hash(), "size", meta.size, "err", err) + return err + } + // is this a possible replacent? If so, we need to consider the storage size difference + // instead of the full size of the new transaction. + if offset < len(p.index[from]) { + storageSizeDiff -= p.index[from][offset].storageSize + } + if p.stored+uint64(storageSizeDiff) > p.config.Datacap { if p.evict.Underpriced(meta) { log.Trace("Dropping underpriced blob transaction", "tx", tx.Hash(), "feecap", tx.GasFeeCap(), "tipcap", tx.GasTipCap(), "blobfeecap", tx.BlobGasFeeCap()) return txpool.ErrUnderpriced From 102a272f00a2de513f4f0fb7d01c2bae9945c730 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Tue, 3 Mar 2026 11:16:54 +0100 Subject: [PATCH 12/16] core/txpool/blobpool: remove underpriced check after add Now that we use the correct size before, this is not needed anyome. Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 7d6534ca03..6459db1046 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1730,15 +1730,6 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error } p.updateStorageMetrics() - // If we've just dropped the added transaction, it was clearly underpriced. - // We've already checked for this with approximate size, but do a final - // check in case it was dropped with the exact size. - if !p.lookup.exists(tx.Hash()) { - log.Trace("Added blob transaction was dropped immediately, indicating underpricing", "hash", tx.Hash()) - addUnderpricedMeter.Mark(1) - return txpool.ErrUnderpriced - } - addValidMeter.Mark(1) // Notify all listeners of the new arrival From b04fb3e68893dc8279bba77cd7a8e7b9c1b806c9 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Thu, 5 Mar 2026 10:50:49 +0100 Subject: [PATCH 13/16] fix storage size calculation Exact storage size calculation needs RLP encoding. Since this is not cheap, we also move the Reserver check before this. Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 104 ++++++++++++++++--------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 6459db1046..b3b1f934d3 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1564,56 +1564,10 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error return err } - // Create meta, in preparation of adding to the pool. - // Having the meta simplifies the check below for underpriced transactions. - meta := newBlobTxMeta(tx) - - // Calculate the eviction parameters for the transaction - var ( - from, _ = types.Sender(p.signer, tx) // already validated above - next = p.state.GetNonce(from) - offset = int(meta.nonce - next) - ) - meta.evictionExecTip = meta.execTipCap - meta.evictionExecFeeJumps = meta.basefeeJumps - meta.evictionBlobFeeJumps = meta.blobfeeJumps - if meta.nonce > next { // transaction can't be gapped, we filter for that in validateTx - prev := p.index[from][int(meta.nonce-next-1)] - if meta.evictionExecTip.Cmp(prev.evictionExecTip) > 0 { - meta.evictionExecTip = prev.evictionExecTip - } - if meta.evictionExecFeeJumps > prev.evictionExecFeeJumps { - meta.evictionExecFeeJumps = prev.evictionExecFeeJumps - } - if meta.evictionBlobFeeJumps > prev.evictionBlobFeeJumps { - meta.evictionBlobFeeJumps = prev.evictionBlobFeeJumps - } - } - - // Check pool size limits before inserting the transaction - // If at limit, check whether it is underpriced. - // Note: equal priority as the current worse of the pool is still considered - // underpriced. This is to prevent constant replacement when the pool is full. - storageSizeDiff, err := getSlotSize(p.slotter, uint32(meta.size)) - if err != nil { - // This should nver happen, but better safe than sorry. - log.Warn("Dropping blob transaction due to size", "tx", tx.Hash(), "size", meta.size, "err", err) - return err - } - // is this a possible replacent? If so, we need to consider the storage size difference - // instead of the full size of the new transaction. - if offset < len(p.index[from]) { - storageSizeDiff -= p.index[from][offset].storageSize - } - if p.stored+uint64(storageSizeDiff) > p.config.Datacap { - if p.evict.Underpriced(meta) { - log.Trace("Dropping underpriced blob transaction", "tx", tx.Hash(), "feecap", tx.GasFeeCap(), "tipcap", tx.GasTipCap(), "blobfeecap", tx.BlobGasFeeCap()) - return txpool.ErrUnderpriced - } - } - // If the address is not yet known, request exclusivity to track the account + // This is a cheap check, so we do it before all the other checks. // only by this subpool until all transactions are evicted + from, _ := types.Sender(p.signer, tx) // already validated above if _, ok := p.index[from]; !ok { if err := p.reserver.Hold(from); err != nil { addNonExclusiveMeter.Mark(1) @@ -1631,13 +1585,63 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error } }() } - // Transaction permitted into the pool from a nonce and cost perspective, - // insert it into the database and update the indices + + // Create meta, in preparation of adding to the pool. + // Having the meta simplifies the check below for underpriced transactions. + // Note: the meta will be finalized with storage information after the transaction is stored + meta := newBlobTxMeta(tx) + + // Calculate the eviction parameters for the transaction + var ( + next = p.state.GetNonce(from) + offset = int(meta.nonce - next) + ) + meta.evictionExecTip = meta.execTipCap + meta.evictionExecFeeJumps = meta.basefeeJumps + meta.evictionBlobFeeJumps = meta.blobfeeJumps + if meta.nonce > next { // transaction can't be gapped, we filter for that in validateTx + prev := p.index[from][int(meta.nonce-next-1)] + if meta.evictionExecTip.Cmp(prev.evictionExecTip) > 0 { + meta.evictionExecTip = prev.evictionExecTip + } + if meta.evictionExecFeeJumps > prev.evictionExecFeeJumps { + meta.evictionExecFeeJumps = prev.evictionExecFeeJumps + } + if meta.evictionBlobFeeJumps > prev.evictionBlobFeeJumps { + meta.evictionBlobFeeJumps = prev.evictionBlobFeeJumps + } + } + + // Check pool size limits before inserting the transaction. For this calculation + // we have to RLP encode the transaction to get the size. + // Note: equal priority as the current worse of the pool is still considered + // underpriced. This is to prevent constant replacement when the pool is full. blob, err := rlp.EncodeToBytes(tx) if err != nil { + // This should never happen, but better safe than sorry. log.Error("Failed to encode transaction for storage", "hash", tx.Hash(), "err", err) return err } + storageSizeDiff, err := getSlotSize(p.slotter, uint32(len(blob))) + if err != nil { + // This should also not happen at this stage + log.Warn("Dropping blob transaction due to size", "tx", tx.Hash(), "size", meta.size, "err", err) + return err + } + // is this a possible replacent? If so, we need to consider the storage size difference + // instead of the full size of the new transaction. + if offset < len(p.index[from]) { + storageSizeDiff -= p.index[from][offset].storageSize + } + if p.stored+uint64(storageSizeDiff) > p.config.Datacap { + if p.evict.Underpriced(meta) { + log.Trace("Dropping underpriced blob transaction", "tx", tx.Hash(), "feecap", tx.GasFeeCap(), "tipcap", tx.GasTipCap(), "blobfeecap", tx.BlobGasFeeCap()) + return txpool.ErrUnderpriced + } + } + + // Transaction permitted into the pool from a nonce and cost perspective, + // insert it into the database and update the indices id, err := p.store.Put(blob) if err != nil { return err From 61c8293a2db91c418fa3bbd233470f0546516bfb Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Fri, 6 Mar 2026 10:26:43 +0100 Subject: [PATCH 14/16] core/txpool/blobpool: fix slotter closure consumption bug billy.SlotSizeFn is a stateful closure that advances an internal counter on each call. Storing it as p.slotter and calling getSlotSize(p.slotter, ...) on every addLocked permanently mutates the closure state. After the first call, all subsequent slot size lookups start from the wrong position and return incorrect sizes. Replace the slotter field with a factory function (newSlotter) that creates a fresh slotter instance on each call to getSlotSize. Signed-off-by: Csaba Kiraly --- core/txpool/blobpool/blobpool.go | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index b3b1f934d3..0dd341939c 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -346,10 +346,10 @@ type BlobPool struct { reserver txpool.Reserver // Address reserver to ensure exclusivity across subpools hasPendingAuth func(common.Address) bool // Determine whether the specified address has a pending 7702-auth - store billy.Database // Persistent data store for the tx metadata and blobs - slotter billy.SlotSizeFn // Slotter function to determine the shelf sizes for the billy database - stored uint64 // Useful data size of all transactions on disk - limbo *limbo // Persistent data store for the non-finalized blobs + store billy.Database // Persistent data store for the tx metadata and blobs + newSlotter func() billy.SlotSizeFn // Factory to create fresh slotter instances for slot size lookups + stored uint64 // Useful data size of all transactions on disk + limbo *limbo // Persistent data store for the non-finalized blobs gapped map[common.Address][]*types.Transaction // Transactions that are currently gapped (nonce too high) gappedSource map[common.Hash]common.Address // Source of gapped transactions to allow rechecking on inclusion @@ -436,13 +436,18 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser p.state = state // Create new slotter for pre-Osaka blob configuration. - p.slotter = newSlotter(params.BlobTxMaxBlobs) + slotter := newSlotter(params.BlobTxMaxBlobs) + p.newSlotter = func() billy.SlotSizeFn { return newSlotter(params.BlobTxMaxBlobs) } // See if we need to migrate the queue blob store after fusaka - p.slotter, err = tryMigrate(p.chain.Config(), p.slotter, queuedir) + slotter, err = tryMigrate(p.chain.Config(), slotter, queuedir) if err != nil { return err } + // Update the slotter factory if Osaka is active + if p.chain.Config().OsakaTime != nil { + p.newSlotter = func() billy.SlotSizeFn { return newSlotterEIP7594(params.BlobTxMaxBlobs) } + } // Index all transactions on disk and delete anything unprocessable var fails []uint64 index := func(id uint64, size uint32, blob []byte) { @@ -450,7 +455,7 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser fails = append(fails, id) } } - store, err := billy.Open(billy.Options{Path: queuedir, Repair: true}, p.slotter, index) + store, err := billy.Open(billy.Options{Path: queuedir, Repair: true}, slotter, index) if err != nil { return err } @@ -1622,7 +1627,7 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error log.Error("Failed to encode transaction for storage", "hash", tx.Hash(), "err", err) return err } - storageSizeDiff, err := getSlotSize(p.slotter, uint32(len(blob))) + storageSizeDiff, err := getSlotSize(p.newSlotter(), uint32(len(blob))) if err != nil { // This should also not happen at this stage log.Warn("Dropping blob transaction due to size", "tx", tx.Hash(), "size", meta.size, "err", err) From 4daf354b2c977d93867c279a483c481b117dcae3 Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Fri, 6 Mar 2026 10:31:56 +0100 Subject: [PATCH 15/16] core/txpool/blobpool: fix uint32 underflow in storage size diff storageSizeDiff was a uint32. When replacing a transaction with a smaller one, the subtraction (new - old) would underflow, and the subsequent cast to uint64 would produce a huge value, making the datacap check always trigger incorrectly. Use int64 for the diff instead. When negative (pool shrinking), skip the capacity check entirely. When positive, the cast to uint64 is safe since we've confirmed it's positive. --- core/txpool/blobpool/blobpool.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 0dd341939c..7efb6e9c01 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1619,7 +1619,7 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error // Check pool size limits before inserting the transaction. For this calculation // we have to RLP encode the transaction to get the size. - // Note: equal priority as the current worse of the pool is still considered + // Note: equal priority as the current worst of the pool is still considered // underpriced. This is to prevent constant replacement when the pool is full. blob, err := rlp.EncodeToBytes(tx) if err != nil { @@ -1627,18 +1627,19 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error log.Error("Failed to encode transaction for storage", "hash", tx.Hash(), "err", err) return err } - storageSizeDiff, err := getSlotSize(p.newSlotter(), uint32(len(blob))) + newStorageSize, err := getSlotSize(p.newSlotter(), uint32(len(blob))) if err != nil { // This should also not happen at this stage log.Warn("Dropping blob transaction due to size", "tx", tx.Hash(), "size", meta.size, "err", err) return err } - // is this a possible replacent? If so, we need to consider the storage size difference - // instead of the full size of the new transaction. + // Is this a possible replacement? If so, we need to consider the storage size + // difference instead of the full size of the new transaction. + storageSizeDiff := int64(newStorageSize) if offset < len(p.index[from]) { - storageSizeDiff -= p.index[from][offset].storageSize + storageSizeDiff -= int64(p.index[from][offset].storageSize) } - if p.stored+uint64(storageSizeDiff) > p.config.Datacap { + if storageSizeDiff > 0 && p.stored+uint64(storageSizeDiff) > p.config.Datacap { if p.evict.Underpriced(meta) { log.Trace("Dropping underpriced blob transaction", "tx", tx.Hash(), "feecap", tx.GasFeeCap(), "tipcap", tx.GasTipCap(), "blobfeecap", tx.BlobGasFeeCap()) return txpool.ErrUnderpriced From f8a0b6fa1c0d7b919203b605fb73eb35c2d955fc Mon Sep 17 00:00:00 2001 From: Csaba Kiraly Date: Fri, 6 Mar 2026 10:36:45 +0100 Subject: [PATCH 16/16] work around billy slotter interface limitations --- core/txpool/blobpool/blobpool.go | 23 ++++++++------- core/txpool/blobpool/slotter.go | 50 +++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 7efb6e9c01..7d86730c92 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -346,10 +346,10 @@ type BlobPool struct { reserver txpool.Reserver // Address reserver to ensure exclusivity across subpools hasPendingAuth func(common.Address) bool // Determine whether the specified address has a pending 7702-auth - store billy.Database // Persistent data store for the tx metadata and blobs - newSlotter func() billy.SlotSizeFn // Factory to create fresh slotter instances for slot size lookups - stored uint64 // Useful data size of all transactions on disk - limbo *limbo // Persistent data store for the non-finalized blobs + store billy.Database // Persistent data store for the tx metadata and blobs + slotter slotSizer // O(1) slot size calculator matching the active billy shelves + stored uint64 // Useful data size of all transactions on disk + limbo *limbo // Persistent data store for the non-finalized blobs gapped map[common.Address][]*types.Transaction // Transactions that are currently gapped (nonce too high) gappedSource map[common.Hash]common.Address // Source of gapped transactions to allow rechecking on inclusion @@ -437,16 +437,19 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser // Create new slotter for pre-Osaka blob configuration. slotter := newSlotter(params.BlobTxMaxBlobs) - p.newSlotter = func() billy.SlotSizeFn { return newSlotter(params.BlobTxMaxBlobs) } // See if we need to migrate the queue blob store after fusaka slotter, err = tryMigrate(p.chain.Config(), slotter, queuedir) if err != nil { return err } - // Update the slotter factory if Osaka is active + // Build an O(1) slot size calculator from the active slotter configuration. + // We need a fresh slotter instance since tryMigrate may have consumed the + // previous one, and billy.Open below will consume this one. if p.chain.Config().OsakaTime != nil { - p.newSlotter = func() billy.SlotSizeFn { return newSlotterEIP7594(params.BlobTxMaxBlobs) } + p.slotter = newSlotSizer(newSlotterEIP7594(params.BlobTxMaxBlobs)) + } else { + p.slotter = newSlotSizer(newSlotter(params.BlobTxMaxBlobs)) } // Index all transactions on disk and delete anything unprocessable var fails []uint64 @@ -1593,7 +1596,7 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error // Create meta, in preparation of adding to the pool. // Having the meta simplifies the check below for underpriced transactions. - // Note: the meta will be finalized with storage information after the transaction is stored + // Note: the meta will be finalized with storage information after the transaction is stored. meta := newBlobTxMeta(tx) // Calculate the eviction parameters for the transaction @@ -1627,9 +1630,9 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error log.Error("Failed to encode transaction for storage", "hash", tx.Hash(), "err", err) return err } - newStorageSize, err := getSlotSize(p.newSlotter(), uint32(len(blob))) + newStorageSize, err := p.slotter.getSlotSize(uint32(len(blob))) if err != nil { - // This should also not happen at this stage + // This should never happen, but better safe than sorry. log.Warn("Dropping blob transaction due to size", "tx", tx.Hash(), "size", meta.size, "err", err) return err } diff --git a/core/txpool/blobpool/slotter.go b/core/txpool/blobpool/slotter.go index 26e3116657..3760b7c293 100644 --- a/core/txpool/blobpool/slotter.go +++ b/core/txpool/blobpool/slotter.go @@ -23,17 +23,47 @@ import ( "github.com/holiman/billy" ) -// getSlotSize return the storage size for a given transaction size based on the current slotter. -func getSlotSize(slotter billy.SlotSizeFn, size uint32) (uint32, error) { - for { - slotSize, done := slotter() - if size <= slotSize { - return slotSize, nil - } - if done { - return size, errors.New("size exceeds maximum slot size") - } +// slotSizer computes the storage shelf size for a given transaction size using +// O(1) arithmetic. Shelf sizes form an arithmetic sequence: +// +// base, base+step, base+2*step, ... +// +// This mirrors the progression in newSlotter and newSlotterEIP7594, but avoids +// creating and iterating a stateful closure on every lookup. +type slotSizer struct { + base uint32 // Size of the first shelf (txAvgSize) + step uint32 // Size increment per subsequent shelf + max uint32 // Largest valid shelf size +} + +// newSlotSizer creates a slotSizer by consuming a slotter closure once to +// discover its base size, step size, and maximum shelf size. +func newSlotSizer(slotter billy.SlotSizeFn) slotSizer { + first, done := slotter() + if done { + return slotSizer{base: first, step: 0, max: first} } + second, done := slotter() + step := second - first + last := second + for !done { + last, done = slotter() + } + return slotSizer{base: first, step: step, max: last} +} + +// getSlotSize returns the shelf size that can store a transaction of the given +// byte size, or an error if it exceeds the largest shelf. +func (s slotSizer) getSlotSize(size uint32) (uint32, error) { + if size <= s.base { + return s.base, nil + } + // Round up to the nearest shelf: base + ⌈(size-base)/step⌉ * step + slot := s.base + ((size-s.base+s.step-1)/s.step)*s.step + if slot > s.max { + return 0, errors.New("size exceeds maximum slot size") + } + return slot, nil } // tryMigrate checks if the billy needs to be migrated and migrates if needed.