miner: consider additional sender nonces when ordering txs

This commit is contained in:
jonny rhea 2026-03-20 11:26:40 -05:00
parent 189f9d0b17
commit 5283e97b02
2 changed files with 157 additions and 9 deletions

View file

@ -33,24 +33,80 @@ type txWithMinerFee struct {
fees *uint256.Int
}
// newTxWithMinerFee creates a wrapped transaction, calculating the effective
// miner gasTipCap if a base fee is provided.
// Returns error in case of a negative effective miner gasTipCap.
func newTxWithMinerFee(tx *txpool.LazyTransaction, from common.Address, baseFee *uint256.Int) (*txWithMinerFee, error) {
// maxLookahead is the maximum number of queued transactions to consider when
// computing a sender's look-ahead score. This bounds the CPU cost of the
// optimal-prefix search during heap construction and on every Shift.
const maxLookahead = 16
// effectiveTip computes the effective miner tip for a single transaction.
func effectiveTip(tx *txpool.LazyTransaction, baseFee *uint256.Int) *uint256.Int {
tip := new(uint256.Int).Set(tx.GasTipCap)
if baseFee != nil {
if tx.GasFeeCap.Cmp(baseFee) < 0 {
return nil, types.ErrGasFeeCapTooLow
return nil // cannot pay base fee
}
tip = new(uint256.Int).Sub(tx.GasFeeCap, baseFee)
if tip.Gt(tx.GasTipCap) {
tip = tx.GasTipCap
tip = new(uint256.Int).Set(tx.GasTipCap)
}
}
return tip
}
// queueScore computes the optimal look-ahead score for a sender's pending
// transaction queue. It finds the prefix of the nonce-ordered sequence that
// maximizes the weighted-average effective tip per gas. This allows the miner
// to "see through" a low-tip head transaction to high-value ones behind it.
//
// For example, if a sender has [0.01 gwei tip @ 21k gas, 100 gwei tip @ 21k gas],
// the head-only score would be 0.01 gwei, but the optimal prefix score is
// ~50 gwei — correctly reflecting that committing both yields high revenue.
func queueScore(headTip *uint256.Int, headGas uint64, pending []*txpool.LazyTransaction, baseFee *uint256.Int) *uint256.Int {
best := new(uint256.Int).Set(headTip)
if len(pending) == 0 {
return best
}
// Running weighted sum: sum(tip_i * gas_i) and sum(gas_i)
sumTipGas := new(uint256.Int).Mul(headTip, new(uint256.Int).SetUint64(headGas))
sumGas := new(uint256.Int).SetUint64(headGas)
lookahead := len(pending)
if lookahead > maxLookahead {
lookahead = maxLookahead
}
for i := 0; i < lookahead; i++ {
tip := effectiveTip(pending[i], baseFee)
if tip == nil {
break // tx can't pay base fee, stop looking ahead
}
gas := new(uint256.Int).SetUint64(pending[i].Gas)
sumTipGas.Add(sumTipGas, new(uint256.Int).Mul(tip, gas))
sumGas.Add(sumGas, gas)
avg := new(uint256.Int).Div(sumTipGas, sumGas)
if avg.Gt(best) {
best.Set(avg)
}
}
return best
}
// newTxWithMinerFee creates a wrapped transaction, calculating the effective
// miner gasTipCap if a base fee is provided. The pending slice contains the
// sender's subsequent queued transactions (nonce-ordered); when non-empty, a
// look-ahead score is computed so that a low-tip head transaction does not hide
// high-value transactions behind it.
// Returns error in case of a negative effective miner gasTipCap.
func newTxWithMinerFee(tx *txpool.LazyTransaction, from common.Address, baseFee *uint256.Int, pending []*txpool.LazyTransaction) (*txWithMinerFee, error) {
tip := effectiveTip(tx, baseFee)
if tip == nil {
return nil, types.ErrGasFeeCapTooLow
}
score := queueScore(tip, tx.Gas, pending, baseFee)
return &txWithMinerFee{
tx: tx,
from: from,
fees: tip,
fees: score,
}, nil
}
@ -107,7 +163,7 @@ func newTransactionsByPriceAndNonce(signer types.Signer, txs map[common.Address]
// Initialize a price and received time based heap with the head transactions
heads := make(txByPriceAndTime, 0, len(txs))
for from, accTxs := range txs {
wrapped, err := newTxWithMinerFee(accTxs[0], from, baseFeeUint)
wrapped, err := newTxWithMinerFee(accTxs[0], from, baseFeeUint, accTxs[1:])
if err != nil {
delete(txs, from)
continue
@ -138,7 +194,7 @@ func (t *transactionsByPriceAndNonce) Peek() (*txpool.LazyTransaction, *uint256.
func (t *transactionsByPriceAndNonce) Shift() {
acc := t.heads[0].from
if txs, ok := t.txs[acc]; ok && len(txs) > 0 {
if wrapped, err := newTxWithMinerFee(txs[0], acc, t.baseFee); err == nil {
if wrapped, err := newTxWithMinerFee(txs[0], acc, t.baseFee, txs[1:]); err == nil {
t.heads[0], t.txs[acc] = wrapped, txs[1:]
heap.Fix(&t.heads, 0)
return

View file

@ -138,6 +138,98 @@ func testTransactionPriceNonceSort(t *testing.T, baseFee *big.Int) {
}
}
// TestTransactionLookahead verifies that a sender with a low-tip head transaction
// followed by a high-tip transaction is promoted above a sender whose single
// transaction has a tip between the two. Without look-ahead scoring the low-tip
// head would bury the high-value transaction.
func TestTransactionLookahead(t *testing.T) {
t.Parallel()
signer := types.LatestSignerForChainID(common.Big1)
baseFee := big.NewInt(10)
keyA, _ := crypto.GenerateKey()
keyB, _ := crypto.GenerateKey()
addrA := crypto.PubkeyToAddress(keyA.PublicKey)
addrB := crypto.PubkeyToAddress(keyB.PublicKey)
// Sender A: nonce 0 has tip 1, nonce 1 has tip 100.
// Head-only score = 1, but look-ahead average ≈ 50.
txA0, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{
Nonce: 0,
To: &common.Address{},
Gas: 100,
GasFeeCap: big.NewInt(11), // baseFee + 1
GasTipCap: big.NewInt(1),
}), signer, keyA)
txA1, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{
Nonce: 1,
To: &common.Address{},
Gas: 100,
GasFeeCap: big.NewInt(110), // baseFee + 100
GasTipCap: big.NewInt(100),
}), signer, keyA)
// Sender B: single tx with tip 20.
// Without look-ahead, B (tip 20) would rank above A (tip 1).
// With look-ahead, A's score (~50) should rank above B (20).
txB0, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{
Nonce: 0,
To: &common.Address{},
Gas: 100,
GasFeeCap: big.NewInt(30), // baseFee + 20
GasTipCap: big.NewInt(20),
}), signer, keyB)
now := time.Now()
groups := map[common.Address][]*txpool.LazyTransaction{
addrA: {
{Hash: txA0.Hash(), Tx: txA0, Time: now, GasFeeCap: uint256.NewInt(11), GasTipCap: uint256.NewInt(1), Gas: 100},
{Hash: txA1.Hash(), Tx: txA1, Time: now, GasFeeCap: uint256.NewInt(110), GasTipCap: uint256.NewInt(100), Gas: 100},
},
addrB: {
{Hash: txB0.Hash(), Tx: txB0, Time: now, GasFeeCap: uint256.NewInt(30), GasTipCap: uint256.NewInt(20), Gas: 100},
},
}
txset := newTransactionsByPriceAndNonce(signer, groups, baseFee)
// First tx out should be A's nonce 0 (sender A ranked higher due to look-ahead).
first, _ := txset.Peek()
if first == nil {
t.Fatal("expected a transaction")
}
if first.Hash != txA0.Hash() {
t.Errorf("expected sender A's tx first (look-ahead should promote it), got sender B")
}
txset.Shift()
// Second should be A's nonce 1 (tip 100 > B's tip 20).
second, _ := txset.Peek()
if second == nil {
t.Fatal("expected a transaction")
}
if second.Hash != txA1.Hash() {
t.Errorf("expected sender A's nonce 1 second, got %s", second.Hash)
}
txset.Shift()
// Third should be B's tx.
third, _ := txset.Peek()
if third == nil {
t.Fatal("expected a transaction")
}
if third.Hash != txB0.Hash() {
t.Errorf("expected sender B's tx third, got %s", third.Hash)
}
txset.Shift()
// Should be empty now.
if last, _ := txset.Peek(); last != nil {
t.Error("expected no more transactions")
}
}
// Tests that if multiple transactions have the same price, the ones seen earlier
// are prioritized to avoid network spam attacks aiming for a specific ordering.
func TestTransactionTimeSort(t *testing.T) {