This commit is contained in:
Csaba Kiraly 2026-02-26 01:33:15 +01:00 committed by GitHub
commit 40c0b97b22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 157 additions and 84 deletions

View file

@ -104,6 +104,16 @@ const (
// maxGappedTxs is the maximum number of gapped transactions kept overall.
// This is a safety limit to avoid DoS vectors.
maxGapped = 128
// notifyThreshold is the eviction priority threshold above which a transaction
// is considered close enough to being includable to be announced to peers.
// Setting this to zero will disable announcements for anyting not immediately
// includable. Setting it to -1 allows transactions that are close to being
// includable, maybe already in the next block if fees go down, to be announced.
// Note, this threshold is in the abstract eviction priority space, so its
// meaning depends on the current basefee/blobfee and the transaction's fees.
announceThreshold = -1
)
// blobTxMeta is the minimal subset of types.BlobTx necessary to validate and
@ -115,6 +125,8 @@ type blobTxMeta struct {
vhashes []common.Hash // Blob versioned hashes to maintain the lookup table
version byte // Blob transaction version to determine proof type
announced bool // Whether the tx has been announced to listeners
id uint64 // Storage ID in the pool's persistent store
storageSize uint32 // Byte size in the pool's persistent store
size uint64 // RLP-encoded size of transaction including the attached blob
@ -159,7 +171,7 @@ func newBlobTxMeta(id uint64, size uint64, storageSize uint32, tx *types.Transac
blobGas: tx.BlobGas(),
}
meta.basefeeJumps = dynamicFeeJumps(meta.execFeeCap)
meta.blobfeeJumps = dynamicFeeJumps(meta.blobFeeCap)
meta.blobfeeJumps = dynamicBlobFeeJumps(meta.blobFeeCap)
return meta
}
@ -210,6 +222,14 @@ func newBlobTxMeta(id uint64, size uint64, storageSize uint32, tx *types.Transac
// via a normal transaction. It should nonetheless be high enough to support
// resurrecting reorged transactions. Perhaps 4-16.
//
// - It is not the role of the blobpool to serve as a storage for limit orders
// below market: blob transactions with fee caps way below base fee or blob fee.
// Therefore, the propagation of blob transactions that are far from being
// includable is suppressed. The pool will only announce blob transactions that
// are close to being includable (based on the current fees and the transaction's
// fee caps), and will delay the announcement of blob transactions that are far
// from being includable until base fee and/or blob fee is reduced.
//
// - Local txs are meaningless. Mining pools historically used local transactions
// for payouts or for backdoor deals. With 1559 in place, the basefee usually
// dominates the final price, so 0 or non-0 tip doesn't change much. Blob txs
@ -281,47 +301,54 @@ func newBlobTxMeta(id uint64, size uint64, storageSize uint32, tx *types.Transac
// solve after every block.
//
// - The first observation is that comparing 1559 base fees or 4844 blob fees
// needs to happen in the context of their dynamism. Since these fees jump
// up or down in ~1.125 multipliers (at max) across blocks, comparing fees
// in two transactions should be based on log1.125(fee) to eliminate noise.
// needs to happen in the context of their dynamism. Since base fees are
// adjusted continuously and fluctuate, and we want to optimize for effective
// miner fees, it is better to disregard small base fee cap differences.
// Instead of considering the exact fee cap values, we should group
// transactions into buckets based on fee cap values, allowing us to use
// the miner tip meaningfully as a splitter inside a bucket.
//
// - The second observation is that the basefee and blobfee move independently,
// so there's no way to split mixed txs on their own (A has higher base fee,
// B has higher blob fee). Rather than look at the absolute fees, the useful
// metric is the max time it can take to exceed the transaction's fee caps.
// To create these buckets, rather than looking at the absolute fee
// differences, the useful metric is the max time it can take to exceed the
// transaction's fee caps. Base fee changes are multiplicative, so we use a
// logarithmic scale. Fees jumps up or down in ~1.125 multipliers at max
// across blocks, so we use log1.125(fee) and rounding to eliminate noise.
// Specifically, we're interested in the number of jumps needed to go from
// the current fee to the transaction's cap:
//
// jumps = log1.125(txfee) - log1.125(basefee)
// jumps = floor(log1.125(txfee) - log1.125(basefee))
//
// - The third observation is that the base fee tends to hover around rather
// than swing wildly. The number of jumps needed from the current fee starts
// to get less relevant the higher it is. To remove the noise here too, the
// pool will use log(jumps) as the delta for comparing transactions.
// For blob fees, EIP-7892 changed the ratio of target to max blobs, and
// with that also the maximum blob fee decrease in a slot from 1.125 to
// approx 1.17. therefore, we use:
//
// delta = sign(jumps) * log(abs(jumps))
// blobfeeJumps = floor(log1.17(txBlobfee) - log1.17(blobfee))
//
// - To establish a total order, we need to reduce the dimensionality of the
// - The second observation is that when ranking executable blob txs, it
// does not make sense to grant a later eviction priority to txs with high
// fee caps since it could enable pool wars. As such, any positive priority
// will be grouped together.
//
// priority = min(jumps, 0)
//
// - The third observation is that the basefee and blobfee move independently,
// so there's no way to split mixed txs on their own (A has higher base fee,
// B has higher blob fee).
//
// To establish a total order, we need to reduce the dimensionality of the
// two base fees (log jumps) to a single value. The interesting aspect from
// the pool's perspective is how fast will a tx get executable (fees going
// down, crossing the smaller negative jump counter) or non-executable (fees
// going up, crossing the smaller positive jump counter). As such, the pool
// cares only about the min of the two delta values for eviction priority.
//
// priority = min(deltaBasefee, deltaBlobfee)
// priority = min(deltaBasefee, deltaBlobfee, 0)
//
// - The above very aggressive dimensionality and noise reduction should result
// in transaction being grouped into a small number of buckets, the further
// the fees the larger the buckets. This is good because it allows us to use
// the miner tip meaningfully as a splitter.
//
// - For the scenario where the pool does not contain non-executable blob txs
// anymore, it does not make sense to grant a later eviction priority to txs
// with high fee caps since it could enable pool wars. As such, any positive
// priority will be grouped together.
//
// priority = min(deltaBasefee, deltaBlobfee, 0)
//
// Optimisation tradeoffs:
//
// - Eviction relies on 3 fee minimums per account (exec tip, exec cap and blob
@ -470,6 +497,20 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser
}
p.evict = newPriceHeap(basefee, blobfee, p.index)
// Guess what was announced. This is needed because we don't want to
// participate in the diffusion of transactions where inclusion is blocked by
// a low base fee transaction. Since we don't persist that info, the best
// we can do is to assume that anything that could have been announced
// at current prices, actually was.
for addr := range p.index {
for _, tx := range p.index[addr] {
tx.announced = p.isAnnouncable(tx)
if !tx.announced {
break
}
}
}
// Pool initialized, attach the blob limbo to it to track blobs included
// recently but not yet finalized
p.limbo, err = newLimbo(p.chain.Config(), limbodir)
@ -517,6 +558,7 @@ func (p *BlobPool) Close() error {
// parseTransaction is a callback method on pool creation that gets called for
// each transaction on disk to create the in-memory metadata index.
// Announced state is not initialized here, it needs to be iniitalized seprately.
func (p *BlobPool) parseTransaction(id uint64, size uint32, blob []byte) error {
tx := new(types.Transaction)
if err := rlp.DecodeBytes(blob, tx); err != nil {
@ -893,6 +935,37 @@ func (p *BlobPool) Reset(oldHead, newHead *types.Header) {
}
p.evict.reinit(basefee, blobfee, false)
// Announce transactions that became announcable due to fee changes
var announcable []*types.Transaction
for addr, txs := range p.index {
for i, meta := range txs {
if !meta.announced && (i == 0 || txs[i-1].announced) && p.isAnnouncable(meta) {
// Load the full transaction and strip the sidecar before announcing
// TODO: this is a bit ugly, as we have everything needed in meta already
data, err := p.store.Get(meta.id)
// Technically, we are supposed to set announced only if Get is successful.
// However, Get failing here indicates a more serious issue (data loss),
// so we set announced anyway to avoid repeated attempts.
meta.announced = true
if err != nil {
log.Error("Blobs missing for announcable transaction", "from", addr, "nonce", meta.nonce, "id", meta.id, "err", err)
continue
}
var tx types.Transaction
if err = rlp.DecodeBytes(data, &tx); err != nil {
log.Error("Blobs corrupted for announcable transaction", "from", addr, "nonce", meta.nonce, "id", meta.id, "err", err)
continue
}
announcable = append(announcable, tx.WithoutBlobTxSidecar())
log.Trace("Blob transaction now announcable", "from", addr, "nonce", meta.nonce, "id", meta.id, "hash", tx.Hash())
}
}
}
if len(announcable) > 0 {
p.discoverFeed.Send(core.NewTxsEvent{Txs: announcable})
}
// Update the basefee and blobfee metrics
basefeeGauge.Update(int64(basefee.Uint64()))
blobfeeGauge.Update(int64(blobfee.Uint64()))
p.updateStorageMetrics()
@ -1673,9 +1746,15 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error
addValidMeter.Mark(1)
// Notify all listeners of the new arrival
p.discoverFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{tx.WithoutBlobTxSidecar()}})
p.insertFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{tx.WithoutBlobTxSidecar()}})
// Transaction was addded successfully, but we only announce if it is (close to being)
// includable and the previous one was already announced.
if p.isAnnouncable(meta) && (meta.nonce == next || (len(txs) > 1 && txs[offset-1].announced)) {
meta.announced = true
p.discoverFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{tx.WithoutBlobTxSidecar()}})
p.insertFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{tx.WithoutBlobTxSidecar()}})
} else {
log.Trace("Blob transaction not announcable yet", "hash", tx.Hash(), "nonce", tx.Nonce())
}
//check the gapped queue for this account and try to promote
if gtxs, ok := p.gapped[from]; checkGapped && ok && len(gtxs) > 0 {
@ -1999,6 +2078,15 @@ func (p *BlobPool) evictGapped() {
}
}
// isAnnouncable checks whether a transaction is announcable based on its
// fee parameters and announceThreshold.
func (p *BlobPool) isAnnouncable(meta *blobTxMeta) bool {
if evictionPriority(p.evict.basefeeJumps, meta.basefeeJumps, p.evict.blobfeeJumps, meta.blobfeeJumps) >= announceThreshold {
return true
}
return false
}
// Stats retrieves the current pool stats, namely the number of pending and the
// number of queued (non-executable) transactions.
func (p *BlobPool) Stats() (int, int) {

View file

@ -830,8 +830,8 @@ func TestOpenIndex(t *testing.T) {
//blobfeeJumps = []float64{34.023, 35.570, 36.879, 29.686, 26.243, 20.358} // log 1.125 (blob fee cap)
evictExecTipCaps = []uint64{10, 10, 5, 5, 1, 1}
evictExecFeeJumps = []float64{39.098, 38.204, 38.204, 19.549, 19.549, 19.549} // min(log 1.125 (exec fee cap))
evictBlobFeeJumps = []float64{34.023, 34.023, 34.023, 29.686, 26.243, 20.358} // min(log 1.125 (blob fee cap))
evictExecFeeJumps = []float64{39.098, 38.204, 38.204, 19.549, 19.549, 19.549} // min(log 1.125 (exec fee cap))
evictBlobFeeJumps = []float64{25.517256, 25.517256, 25.517256, 22.264502, 19.682646, 15.268934} // min(log 1.17 (blob fee cap))
totalSpent = uint256.NewInt(21000*(100+90+200+10+80+300) + blobSize*(55+66+77+33+22+11) + 100*6) // 21000 gas x price + 128KB x blobprice + value
)
@ -1751,8 +1751,8 @@ func TestAdd(t *testing.T) {
// Create a blob pool out of the pre-seeded dats
chain := &testBlockChain{
config: params.MainnetChainConfig,
basefee: uint256.NewInt(1050),
blobfee: uint256.NewInt(105),
basefee: uint256.NewInt(1),
blobfee: uint256.NewInt(1),
statedb: statedb,
}
pool := New(Config{Datadir: storage}, chain, nil)

View file

@ -67,7 +67,7 @@ func newPriceHeap(basefee *uint256.Int, blobfee *uint256.Int, index map[common.A
func (h *evictHeap) reinit(basefee *uint256.Int, blobfee *uint256.Int, force bool) {
// If the update is mostly the same as the old, don't sort pointlessly
basefeeJumps := dynamicFeeJumps(basefee)
blobfeeJumps := dynamicFeeJumps(blobfee)
blobfeeJumps := dynamicBlobFeeJumps(blobfee)
if !force && math.Abs(h.basefeeJumps-basefeeJumps) < 0.01 && math.Abs(h.blobfeeJumps-blobfeeJumps) < 0.01 { // TODO(karalabe): 0.01 enough, maybe should be smaller? Maybe this optimization is moot?
return
@ -95,13 +95,7 @@ func (h *evictHeap) Less(i, j int) bool {
lastJ := txsJ[len(txsJ)-1]
prioI := evictionPriority(h.basefeeJumps, lastI.evictionExecFeeJumps, h.blobfeeJumps, lastI.evictionBlobFeeJumps)
if prioI > 0 {
prioI = 0
}
prioJ := evictionPriority(h.basefeeJumps, lastJ.evictionExecFeeJumps, h.blobfeeJumps, lastJ.evictionBlobFeeJumps)
if prioJ > 0 {
prioJ = 0
}
if prioI == prioJ {
return lastI.evictionExecTip.Lt(lastJ.evictionExecTip)
}

View file

@ -109,22 +109,22 @@ func TestPriceHeapSorting(t *testing.T) {
order: []int{3, 2, 1, 0, 4, 5, 6},
},
// If both basefee and blobfee is specified, sort by the larger distance
// of the two from the current network conditions, splitting same (loglog)
// of the two from the current network conditions, splitting same
// ones via the tip.
//
// Basefee: 1000
// Blobfee: 100
// Basefee: 1000 , jumps: 888, 790, 702, 624
// Blobfee: 100 , jumps: 85, 73, 62, 53
//
// Tx #0: (800, 80) - 2 jumps below both => priority -1
// Tx #1: (630, 63) - 4 jumps below both => priority -2
// Tx #2: (800, 63) - 2 jumps below basefee, 4 jumps below blobfee => priority -2 (blob penalty dominates)
// Tx #3: (630, 80) - 4 jumps below basefee, 2 jumps below blobfee => priority -2 (base penalty dominates)
// Tx #0: (800, 80) - 2 jumps below both => priority -2
// Tx #1: (630, 55) - 4 jumps below both => priority -4
// Tx #2: (800, 55) - 2 jumps below basefee, 4 jumps below blobfee => priority -4 (blob penalty dominates)
// Tx #3: (630, 80) - 4 jumps below basefee, 2 jumps below blobfee => priority -4 (base penalty dominates)
//
// Txs 1, 2, 3 share the same priority, split via tip, prefer 0 as the best
{
execTips: []uint64{1, 2, 3, 4},
execFees: []uint64{800, 630, 800, 630},
blobFees: []uint64{80, 63, 63, 80},
blobFees: []uint64{80, 55, 55, 80},
basefee: 1000,
blobfee: 100,
order: []int{1, 2, 3, 0},
@ -142,7 +142,7 @@ func TestPriceHeapSorting(t *testing.T) {
blobFee = uint256.NewInt(tt.blobFees[j])
basefeeJumps = dynamicFeeJumps(execFee)
blobfeeJumps = dynamicFeeJumps(blobFee)
blobfeeJumps = dynamicBlobFeeJumps(blobFee)
)
index[addr] = []*blobTxMeta{{
id: uint64(j),
@ -201,7 +201,7 @@ func benchmarkPriceHeapReinit(b *testing.B, datacap uint64) {
blobFee = uint256.NewInt(rnd.Uint64())
basefeeJumps = dynamicFeeJumps(execFee)
blobfeeJumps = dynamicFeeJumps(blobFee)
blobfeeJumps = dynamicBlobFeeJumps(blobFee)
)
index[addr] = []*blobTxMeta{{
id: uint64(i),
@ -277,7 +277,7 @@ func benchmarkPriceHeapOverflow(b *testing.B, datacap uint64) {
blobFee = uint256.NewInt(rnd.Uint64())
basefeeJumps = dynamicFeeJumps(execFee)
blobfeeJumps = dynamicFeeJumps(blobFee)
blobfeeJumps = dynamicBlobFeeJumps(blobFee)
)
index[addr] = []*blobTxMeta{{
id: uint64(i),
@ -308,7 +308,7 @@ func benchmarkPriceHeapOverflow(b *testing.B, datacap uint64) {
blobFee = uint256.NewInt(rnd.Uint64())
basefeeJumps = dynamicFeeJumps(execFee)
blobfeeJumps = dynamicFeeJumps(blobFee)
blobfeeJumps = dynamicBlobFeeJumps(blobFee)
)
metas[i] = &blobTxMeta{
id: uint64(int(blobs) + i),

View file

@ -18,7 +18,6 @@ package blobpool
import (
"math"
"math/bits"
"github.com/holiman/uint256"
)
@ -26,6 +25,13 @@ import (
// log1_125 is used in the eviction priority calculation.
var log1_125 = math.Log(1.125)
// log1_17 is used in the eviction priority calculation for blob fees.
// EIP-7892 (BPO) changed the ratio of target to max blobs, and with that
// also the maximum blob fee decrease in a slot from 1.125 to approx 1.17 .
// Since we want priorities to approximate time, we should change our log
// calculation for blob fees.
var log1_17 = log1_125 * 4 / 3
// evictionPriority calculates the eviction priority based on the algorithm
// described in the BlobPool docs for both fee components.
//
@ -36,23 +42,20 @@ func evictionPriority(basefeeJumps float64, txBasefeeJumps, blobfeeJumps, txBlob
basefeePriority = evictionPriority1D(basefeeJumps, txBasefeeJumps)
blobfeePriority = evictionPriority1D(blobfeeJumps, txBlobfeeJumps)
)
if basefeePriority < blobfeePriority {
return basefeePriority
}
return blobfeePriority
return min(0, basefeePriority, blobfeePriority)
}
// evictionPriority1D calculates the eviction priority based on the algorithm
// described in the BlobPool docs for a single fee component.
func evictionPriority1D(basefeeJumps float64, txfeeJumps float64) int {
jumps := txfeeJumps - basefeeJumps
if int(jumps) == 0 {
return 0 // can't log2 0
if jumps <= 0 {
return int(math.Floor(jumps))
}
if jumps < 0 {
return -intLog2(uint(-math.Floor(jumps)))
}
return intLog2(uint(math.Ceil(jumps)))
// We only use the negative part for ordering. The positive part is only used
// for threshold comparision (with a negative threshold), so the value is almost
// irrelevant, as long as it's positive.
return int((math.Ceil(jumps)))
}
// dynamicFeeJumps calculates the log1.125(fee), namely the number of fee jumps
@ -70,21 +73,9 @@ func dynamicFeeJumps(fee *uint256.Int) float64 {
return math.Log(fee.Float64()) / log1_125
}
// intLog2 is a helper to calculate the integral part of a log2 of an unsigned
// integer. It is a very specific calculation that's not particularly useful in
// general, but it's what we need here (it's fast).
func intLog2(n uint) int {
switch {
case n == 0:
panic("log2(0) is undefined")
case n < 2048:
return bits.UintSize - bits.LeadingZeros(n) - 1
default:
// The input is log1.125(uint256) = log2(uint256) / log2(1.125). At the
// most extreme, log2(uint256) will be a bit below 257, and the constant
// log2(1.125) ~= 0.17. The larges input thus is ~257 / ~0.17 ~= ~1511.
panic("dynamic fee jump diffs cannot reach this")
func dynamicBlobFeeJumps(fee *uint256.Int) float64 {
if fee.IsZero() {
return 0 // can't log2 zero, should never happen outside tests, but don't choke
}
return math.Log(fee.Float64()) / log1_17
}

View file

@ -30,12 +30,12 @@ func TestPriorityCalculation(t *testing.T) {
txfee uint64
result int
}{
{basefee: 7, txfee: 10, result: 2}, // 3.02 jumps, 4 ceil, 2 log2
{basefee: 17_200_000_000, txfee: 17_200_000_000, result: 0}, // 0 jumps, special case 0 log2
{basefee: 9_853_941_692, txfee: 11_085_092_510, result: 0}, // 0.99 jumps, 1 ceil, 0 log2
{basefee: 11_544_106_391, txfee: 10_356_781_100, result: 0}, // -0.92 jumps, -1 floor, 0 log2
{basefee: 17_200_000_000, txfee: 7, result: -7}, // -183.57 jumps, -184 floor, -7 log2
{basefee: 7, txfee: 17_200_000_000, result: 7}, // 183.57 jumps, 184 ceil, 7 log2
{basefee: 7, txfee: 10, result: 4}, // 3.02 jumps, 4 ceil
{basefee: 17_200_000_000, txfee: 17_200_000_000, result: 0}, // 0 jumps, special case 0
{basefee: 9_853_941_692, txfee: 11_085_092_510, result: 1}, // 0.99 jumps, 1 ceil
{basefee: 11_544_106_391, txfee: 10_356_781_100, result: -1}, // -0.92 jumps, -1 floor
{basefee: 17_200_000_000, txfee: 7, result: -184}, // -183.57 jumps, -184 floor
{basefee: 7, txfee: 17_200_000_000, result: 184}, // 183.57 jumps, 184 ceil
}
for i, tt := range tests {
var (
@ -69,7 +69,7 @@ func BenchmarkPriorityCalculation(b *testing.B) {
blobfee := uint256.NewInt(123_456_789_000) // Completely random, no idea what this will be
basefeeJumps := dynamicFeeJumps(basefee)
blobfeeJumps := dynamicFeeJumps(blobfee)
blobfeeJumps := dynamicBlobFeeJumps(blobfee)
// The transaction's fee cap and blob fee cap are constant across the life
// of the transaction, so we can pre-calculate and cache them.
@ -77,7 +77,7 @@ func BenchmarkPriorityCalculation(b *testing.B) {
txBlobfeeJumps := make([]float64, b.N)
for i := 0; i < b.N; i++ {
txBasefeeJumps[i] = dynamicFeeJumps(uint256.NewInt(rnd.Uint64()))
txBlobfeeJumps[i] = dynamicFeeJumps(uint256.NewInt(rnd.Uint64()))
txBlobfeeJumps[i] = dynamicBlobFeeJumps(uint256.NewInt(rnd.Uint64()))
}
b.ResetTimer()
b.ReportAllocs()