From e84b6eb56883fa12ecb3f5bbb2950838592754e3 Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 19 Mar 2026 02:52:39 +0900 Subject: [PATCH 01/24] implement sparse blobpool --- cmd/devp2p/internal/ethtest/conn.go | 2 +- cmd/devp2p/internal/ethtest/suite.go | 14 +- cmd/devp2p/internal/ethtest/transaction.go | 4 +- core/txpool/blobpool/blobpool.go | 728 +++++++++++--- core/txpool/blobpool/blobpool_test.go | 268 ++++-- core/txpool/blobpool/limbo.go | 16 +- core/txpool/blobpool/lookup.go | 11 +- core/txpool/legacypool/legacypool.go | 2 +- core/txpool/subpool.go | 3 +- core/txpool/txpool.go | 4 +- core/txpool/validation.go | 85 +- core/types/custody_bitmap.go | 132 +++ core/types/transaction.go | 12 + core/types/tx_blob.go | 31 + crypto/kzg4844/kzg4844.go | 44 +- crypto/kzg4844/kzg4844_ckzg_cgo.go | 87 ++ crypto/kzg4844/kzg4844_ckzg_nocgo.go | 12 + crypto/kzg4844/kzg4844_gokzg.go | 90 ++ crypto/kzg4844/kzg4844_test.go | 116 +++ eth/backend.go | 4 + eth/catalyst/api.go | 5 + eth/catalyst/api_test.go | 16 +- eth/fetcher/blob_fetcher.go | 784 +++++++++++++++ eth/fetcher/blob_fetcher_test.go | 999 ++++++++++++++++++++ eth/fetcher/metrics.go | 21 + eth/fetcher/tx_fetcher.go | 17 +- eth/fetcher/tx_fetcher_test.go | 2 +- eth/handler.go | 31 +- eth/handler_eth.go | 35 +- eth/handler_eth_test.go | 34 +- eth/handler_test.go | 108 ++- eth/protocols/eth/broadcast.go | 30 +- eth/protocols/eth/handler.go | 41 +- eth/protocols/eth/handler_test.go | 15 +- eth/protocols/eth/handlers.go | 78 +- eth/protocols/eth/handshake_test.go | 2 +- eth/protocols/eth/peer.go | 49 +- eth/protocols/eth/peer_test.go | 2 +- eth/protocols/eth/protocol.go | 59 +- eth/sync_test.go | 4 +- tests/fuzzers/txfetcher/txfetcher_fuzzer.go | 2 +- 41 files changed, 3638 insertions(+), 361 deletions(-) create mode 100644 core/types/custody_bitmap.go create mode 100644 eth/fetcher/blob_fetcher.go create mode 100644 eth/fetcher/blob_fetcher_test.go diff --git a/cmd/devp2p/internal/ethtest/conn.go b/cmd/devp2p/internal/ethtest/conn.go index 98baba81a4..8d1998c921 100644 --- a/cmd/devp2p/internal/ethtest/conn.go +++ b/cmd/devp2p/internal/ethtest/conn.go @@ -167,7 +167,7 @@ func (c *Conn) ReadEth() (any, error) { case eth.TransactionsMsg: msg = new(eth.TransactionsPacket) case eth.NewPooledTransactionHashesMsg: - msg = new(eth.NewPooledTransactionHashesPacket) + msg = new(eth.NewPooledTransactionHashesPacket70) case eth.GetPooledTransactionsMsg: msg = new(eth.GetPooledTransactionsPacket) case eth.PooledTransactionsMsg: diff --git a/cmd/devp2p/internal/ethtest/suite.go b/cmd/devp2p/internal/ethtest/suite.go index 7560c13137..b21fedb96d 100644 --- a/cmd/devp2p/internal/ethtest/suite.go +++ b/cmd/devp2p/internal/ethtest/suite.go @@ -865,7 +865,7 @@ the transactions using a GetPooledTransactions request.`) } // Send announcement. - ann := eth.NewPooledTransactionHashesPacket{Types: txTypes, Sizes: sizes, Hashes: hashes} + ann := eth.NewPooledTransactionHashesPacket70{Types: txTypes, Sizes: sizes, Hashes: hashes} err = conn.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann) if err != nil { t.Fatalf("failed to write to connection: %v", err) @@ -883,7 +883,7 @@ the transactions using a GetPooledTransactions request.`) t.Fatalf("unexpected number of txs requested: wanted %d, got %d", len(hashes), len(msg.GetPooledTransactionsRequest)) } return - case *eth.NewPooledTransactionHashesPacket: + case *eth.NewPooledTransactionHashesPacket70: continue case *eth.TransactionsPacket: continue @@ -949,12 +949,12 @@ func (s *Suite) TestBlobViolations(t *utesting.T) { t2 = s.makeBlobTxs(2, 3, 0x2) ) for _, test := range []struct { - ann eth.NewPooledTransactionHashesPacket + ann eth.NewPooledTransactionHashesPacket70 resp eth.PooledTransactionsResponse }{ // Invalid tx size. { - ann: eth.NewPooledTransactionHashesPacket{ + ann: eth.NewPooledTransactionHashesPacket70{ Types: []byte{types.BlobTxType, types.BlobTxType}, Sizes: []uint32{uint32(t1[0].Size()), uint32(t1[1].Size() + 10)}, Hashes: []common.Hash{t1[0].Hash(), t1[1].Hash()}, @@ -963,7 +963,7 @@ func (s *Suite) TestBlobViolations(t *utesting.T) { }, // Wrong tx type. { - ann: eth.NewPooledTransactionHashesPacket{ + ann: eth.NewPooledTransactionHashesPacket70{ Types: []byte{types.DynamicFeeTxType, types.BlobTxType}, Sizes: []uint32{uint32(t2[0].Size()), uint32(t2[1].Size())}, Hashes: []common.Hash{t2[0].Hash(), t2[1].Hash()}, @@ -1092,7 +1092,7 @@ func (s *Suite) testBadBlobTx(t *utesting.T, tx *types.Transaction, badTx *types return } - ann := eth.NewPooledTransactionHashesPacket{ + ann := eth.NewPooledTransactionHashesPacket70{ Types: []byte{types.BlobTxType}, Sizes: []uint32{uint32(badTx.Size())}, Hashes: []common.Hash{badTx.Hash()}, @@ -1143,7 +1143,7 @@ func (s *Suite) testBadBlobTx(t *utesting.T, tx *types.Transaction, badTx *types return } - ann := eth.NewPooledTransactionHashesPacket{ + ann := eth.NewPooledTransactionHashesPacket70{ Types: []byte{types.BlobTxType}, Sizes: []uint32{uint32(tx.Size())}, Hashes: []common.Hash{tx.Hash()}, diff --git a/cmd/devp2p/internal/ethtest/transaction.go b/cmd/devp2p/internal/ethtest/transaction.go index 8ce26f3e1a..6cebfcea70 100644 --- a/cmd/devp2p/internal/ethtest/transaction.go +++ b/cmd/devp2p/internal/ethtest/transaction.go @@ -74,7 +74,7 @@ func (s *Suite) sendTxs(t *utesting.T, txs []*types.Transaction) error { for _, tx := range txs { got[tx.Hash()] = true } - case *eth.NewPooledTransactionHashesPacket: + case *eth.NewPooledTransactionHashesPacket70: for _, hash := range msg.Hashes { got[hash] = true } @@ -160,7 +160,7 @@ func (s *Suite) sendInvalidTxs(t *utesting.T, txs []*types.Transaction) error { return fmt.Errorf("received bad tx: %s", tx.Hash()) } } - case *eth.NewPooledTransactionHashesPacket: + case *eth.NewPooledTransactionHashesPacket70: for _, hash := range msg.Hashes { if _, ok := invalids[hash]; ok { return fmt.Errorf("received bad tx: %s", hash) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 7155a67a9b..8dd499250b 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -131,6 +131,8 @@ type blobTxMeta struct { storageSize uint32 // Byte size in the pool's persistent store size uint64 // RLP-encoded size of transaction including the attached blob + custody *types.CustodyBitmap + nonce uint64 // Needed to prioritize inclusion order within an account costCap *uint256.Int // Needed to validate cumulative balance sufficiency execTipCap *uint256.Int // Needed to prioritize inclusion order across accounts and validate replacement price bump @@ -147,28 +149,28 @@ type blobTxMeta struct { evictionBlobFeeJumps float64 // Worse blob fee (converted to fee jumps) across all previous nonces } -// newBlobTxMeta retrieves the indexed metadata fields from a blob transaction +// newBlobTxMeta retrieves the indexed metadata fields from a pooled 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 { - if tx.BlobTxSidecar() == nil { +func newBlobTxMeta(id uint64, size uint64, storageSize uint32, pooledTx *pooledBlobTx) *blobTxMeta { + if pooledTx.Sidecar == 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, + hash: pooledTx.Transaction.Hash(), + vhashes: pooledTx.Transaction.BlobHashes(), + version: pooledTx.Sidecar.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(), + nonce: pooledTx.Transaction.Nonce(), + costCap: uint256.MustFromBig(pooledTx.Transaction.Cost()), + execTipCap: uint256.MustFromBig(pooledTx.Transaction.GasTipCap()), + execFeeCap: uint256.MustFromBig(pooledTx.Transaction.GasFeeCap()), + blobFeeCap: uint256.MustFromBig(pooledTx.Transaction.BlobGasFeeCap()), + execGas: pooledTx.Transaction.Gas(), + blobGas: pooledTx.Transaction.BlobGas(), + custody: &pooledTx.Sidecar.Custody, } meta.basefeeJumps = dynamicFeeJumps(meta.execFeeCap) meta.blobfeeJumps = dynamicBlobFeeJumps(meta.blobFeeCap) @@ -176,6 +178,42 @@ func newBlobTxMeta(id uint64, size uint64, storageSize uint32, tx *types.Transac return meta } +type pooledBlobTx struct { + Transaction *types.Transaction + Sidecar *types.BlobTxCellSidecar + Size uint64 // original transaction size (including blobs) +} + +// newPooledBlobTx creates pooledBlobTx struct. +func newPooledBlobTx(tx *types.Transaction) (*pooledBlobTx, error) { + if tx.BlobTxSidecar() == nil { + return nil, errors.New("missing blob sidecar") + } + sidecar, err := tx.BlobTxSidecar().ToBlobTxCellSidecar() + if err != nil { + return nil, err + } + return &pooledBlobTx{ + Transaction: tx.WithoutBlobTxSidecar(), + Sidecar: sidecar, + Size: tx.Size(), + }, nil +} + +// convert recovers blobs from cell sidecar and returns a full transaction with blob sidecar. +func (ptx *pooledBlobTx) convert() (*types.Transaction, error) { + if ptx.Sidecar == nil { + return nil, errors.New("cell sidecar missing") + } + cellSidecar := ptx.Sidecar + blobs, err := kzg4844.RecoverBlobs(cellSidecar.Cells, cellSidecar.Custody.Indices()) + if err != nil { + return nil, err + } + sidecar := types.NewBlobTxSidecar(cellSidecar.Version, blobs, cellSidecar.Commitments, cellSidecar.Proofs) + return ptx.Transaction.WithBlobTxSidecar(sidecar), nil +} + // BlobPool is the transaction pool dedicated to EIP-4844 blob transactions. // // Blob transactions are special snowflakes that are designed for a very specific @@ -367,8 +405,16 @@ type BlobPool struct { 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 + gapped map[common.Address][]*pooledBlobTx // Transactions that are currently gapped (nonce too high) + gappedSource map[common.Hash]common.Address // Source of gapped transactions to allow rechecking on inclusion + + queue map[common.Hash]*types.Transaction // buffer + indexQueue map[common.Address][]*blobTxMeta // tx hashes in queue per address, sorted by nonce + spentQueue map[common.Address]*uint256.Int // Expenditure tracking for accounts, only for buffered txs + replacementQueue map[common.Address]map[uint64]*blobTxMeta // Replacement queue for pooled transactions + + cellQueue map[common.Hash][]kzg4844.Cell // cell buffer + custodyQueue map[common.Hash]*types.CustodyBitmap signer types.Signer // Transaction signer to use for sender recovery chain BlockChain // Chain object to access the state through @@ -396,15 +442,21 @@ func New(config Config, chain BlockChain, hasPendingAuth func(common.Address) bo // Create the transaction pool with its initial settings return &BlobPool{ - config: config, - hasPendingAuth: hasPendingAuth, - signer: types.LatestSigner(chain.Config()), - chain: chain, - lookup: newLookup(), - index: make(map[common.Address][]*blobTxMeta), - spent: make(map[common.Address]*uint256.Int), - gapped: make(map[common.Address][]*types.Transaction), - gappedSource: make(map[common.Hash]common.Address), + config: config, + hasPendingAuth: hasPendingAuth, + signer: types.LatestSigner(chain.Config()), + chain: chain, + lookup: newLookup(), + index: make(map[common.Address][]*blobTxMeta), + spent: make(map[common.Address]*uint256.Int), + gapped: make(map[common.Address][]*pooledBlobTx), + gappedSource: make(map[common.Hash]common.Address), + queue: make(map[common.Hash]*types.Transaction), + indexQueue: make(map[common.Address][]*blobTxMeta), + spentQueue: make(map[common.Address]*uint256.Int), + cellQueue: make(map[common.Hash][]kzg4844.Cell), + custodyQueue: make(map[common.Hash]*types.CustodyBitmap), + replacementQueue: make(map[common.Address]map[uint64]*blobTxMeta), } } @@ -461,9 +513,13 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser } // Index all transactions on disk and delete anything unprocessable var fails []uint64 + var convertTxs []*types.Transaction index := func(id uint64, size uint32, blob []byte) { - if p.parseTransaction(id, size, blob) != nil { + if tx, err := p.parseTransaction(id, size, blob); err != nil { fails = append(fails, id) + } else if tx != nil { + fails = append(fails, id) + convertTxs = append(convertTxs, tx) } } store, err := billy.Open(billy.Options{Path: queuedir, Repair: true}, slotter, index) @@ -472,6 +528,40 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser } p.store = store + // Migrate legacy transactions (types.Transaction) to pooledBlobTx format. + // Legacy entries are deleted (via fails) and re-stored as pooledBlobTx. + if len(convertTxs) > 0 { + log.Trace("Convert plain transaction to pooled transaction", "len", len(convertTxs)) + for _, tx := range convertTxs { + // Note that we skip errors here. + // Just like parseTransaction failure does not abort the blobpool creation, + // conversion process also cannot abort the entire process. + pooledTx, err := newPooledBlobTx(tx) + if err != nil { + log.Error("Failed to convert legacy tx to pooledBlobTx", "hash", tx.Hash(), "err", err) + continue + } + blob, err := rlp.EncodeToBytes(pooledTx) + if err != nil { + continue + } + id, err := p.store.Put(blob) + if err != nil { + continue + } + meta := newBlobTxMeta(id, pooledTx.Size, p.store.Size(id), pooledTx) + + sender, err := types.Sender(p.signer, pooledTx.Transaction) + if err != nil { + fails = append(fails, id) + continue + } + if err := p.trackTransaction(meta, sender); err != nil { + fails = append(fails, id) + continue + } + } + } if len(fails) > 0 { log.Warn("Dropping invalidated blob transactions", "ids", fails) dropInvalidMeter.Mark(int64(len(fails))) @@ -558,36 +648,54 @@ 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 { +// Announced state is not initialized here, it needs to be initialized separately. +// +// If a legacy types.Transaction is found on disk, it is returned for migration +// in Init (the old ID will be deleted and a new pooledBlobTx written). +// If a pooledBlobTx is found, it is indexed directly and nil is returned. +func (p *BlobPool) parseTransaction(id uint64, size uint32, blob []byte) (*types.Transaction, error) { tx := new(types.Transaction) - if err := rlp.DecodeBytes(blob, tx); err != nil { + pooledTx := new(pooledBlobTx) + + if err := rlp.DecodeBytes(blob, pooledTx); err != nil { // This path is impossible unless the disk data representation changes // across restarts. For that ever improbable case, recover gracefully // by ignoring this data entry. - log.Error("Failed to decode blob pool entry", "id", id, "err", err) - return err + if err := rlp.DecodeBytes(blob, tx); err != nil { + log.Error("Failed to decode blob pool entry", "id", id, "err", err) + return nil, err + } + if tx.BlobTxSidecar() == nil { + log.Error("Missing sidecar in blob pool entry", "id", id, "hash", tx.Hash()) + return nil, errors.New("missing blob sidecar") + } + return tx, nil } - if tx.BlobTxSidecar() == nil { - log.Error("Missing sidecar in blob pool entry", "id", id, "hash", tx.Hash()) - return errors.New("missing blob sidecar") + if pooledTx.Sidecar == nil { + log.Error("Missing sidecar in blob pool entry", "id", id, "hash", pooledTx.Transaction.Hash()) + return nil, errors.New("missing blob sidecar") } + meta := newBlobTxMeta(id, pooledTx.Size, size, pooledTx) - meta := newBlobTxMeta(id, tx.Size(), size, tx) - 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 - // partially resurrected. - log.Error("Rejecting duplicate blob pool entry", "id", id, "hash", tx.Hash()) - return errors.New("duplicate blob entry") - } - sender, err := types.Sender(p.signer, tx) + sender, err := types.Sender(p.signer, pooledTx.Transaction) if err != nil { // This path is impossible unless the signature validity changes across // restarts. For that ever improbable case, recover gracefully by ignoring // this data entry. - log.Error("Failed to recover blob tx sender", "id", id, "hash", tx.Hash(), "err", err) - return err + log.Error("Failed to recover blob tx sender", "id", id, "hash", pooledTx.Transaction.Hash(), "err", err) + return nil, err + } + return nil, p.trackTransaction(meta, sender) +} + +// trackTransaction registers a transaction's metadata in the pool's indices. +func (p *BlobPool) trackTransaction(meta *blobTxMeta, sender common.Address) error { + 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 + // partially resurrected. + log.Error("Rejecting duplicate blob pool entry", "id", meta.id, "hash", meta.hash) + return fmt.Errorf("duplicate blob entry %d, %s", meta.id, meta.hash) } if _, ok := p.index[sender]; !ok { if err := p.reserver.Hold(sender); err != nil { @@ -863,17 +971,17 @@ func (p *BlobPool) offload(addr common.Address, nonce uint64, id uint64, inclusi log.Error("Blobs missing for included transaction", "from", addr, "nonce", nonce, "id", id, "err", err) return } - var tx types.Transaction - if err = rlp.DecodeBytes(data, &tx); err != nil { + var pooledTx pooledBlobTx + if err = rlp.DecodeBytes(data, &pooledTx); err != nil { log.Error("Blobs corrupted for included transaction", "from", addr, "nonce", nonce, "id", id, "err", err) return } - block, ok := inclusions[tx.Hash()] + block, ok := inclusions[pooledTx.Transaction.Hash()] if !ok { log.Warn("Blob transaction swapped out by signer", "from", addr, "nonce", nonce, "id", id) return } - if err := p.limbo.push(&tx, block); err != nil { + if err := p.limbo.push(&pooledTx, block); err != nil { log.Warn("Failed to offload blob tx into limbo", "err", err) return } @@ -951,13 +1059,13 @@ func (p *BlobPool) Reset(oldHead, newHead *types.Header) { 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 { + var pooledTx pooledBlobTx + if err = rlp.DecodeBytes(data, &pooledTx); 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()) + announcable = append(announcable, pooledTx.Transaction) + log.Trace("Blob transaction now announcable", "from", addr, "nonce", meta.nonce, "id", meta.id, "hash", pooledTx.Transaction.Hash()) } } } @@ -1116,38 +1224,23 @@ func (p *BlobPool) reinject(addr common.Address, txhash common.Hash) error { // TODO: seems like an easy optimization here would be getting the serialized tx // from limbo instead of re-serializing it here. - // Converts reorged-out legacy blob transactions to the new format to prevent - // them from becoming stuck in the pool until eviction. - // - // Performance note: Conversion takes ~140ms (Mac M1 Pro). Since a maximum of - // 9 legacy blob transactions are allowed in a block pre-Osaka, an adversary - // could theoretically halt a Geth node for ~1.2s by reorging per block. However, - // this attack is financially inefficient to execute. - head := p.head.Load() - if p.chain.Config().IsOsaka(head.Number, head.Time) && tx.BlobTxSidecar().Version == types.BlobSidecarVersion0 { - if err := tx.BlobTxSidecar().ToV1(); err != nil { - log.Error("Failed to convert the legacy sidecar", "err", err) - return err - } - log.Info("Legacy blob transaction is reorged", "hash", tx.Hash()) - } // Serialize the transaction back into the primary datastore. blob, err := rlp.EncodeToBytes(tx) if err != nil { - log.Error("Failed to encode transaction for storage", "hash", tx.Hash(), "err", err) + log.Error("Failed to encode transaction for storage", "hash", tx.Transaction.Hash(), "err", err) return err } id, err := p.store.Put(blob) if err != nil { - log.Error("Failed to write transaction into storage", "hash", tx.Hash(), "err", err) + log.Error("Failed to write transaction into storage", "hash", tx.Transaction.Hash(), "err", err) return err } // Update the indices and metrics - meta := newBlobTxMeta(id, tx.Size(), p.store.Size(id), tx) + meta := newBlobTxMeta(id, tx.Size, p.store.Size(id), tx) 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) + log.Warn("Failed to reserve account for blob pool", "tx", tx.Transaction.Hash(), "from", addr, "err", err) return err } p.index[addr] = []*blobTxMeta{meta} @@ -1276,12 +1369,40 @@ func (p *BlobPool) checkDelegationLimit(tx *types.Transaction) error { return txpool.ErrInflightTxLimitReached } +// ValidateCells validates cells against transaction commitments and proofs. +func (p *BlobPool) ValidateCells(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { + errs := make([]error, len(txs)) + + for i, tx := range txs { + if _, ok := p.queue[tx]; !ok { + errs[i] = fmt.Errorf("transaction %s not found", tx) + continue + } + sidecar := p.queue[tx].BlobTxSidecar() + cellProofs := make([]kzg4844.Proof, 0) + for _, proofIdx := range custody.Indices() { + // should store all proofs + for blobIdx := range len(sidecar.Commitments) { + idx := blobIdx*kzg4844.CellProofsPerBlob + int(proofIdx) + cellProofs = append(cellProofs, sidecar.Proofs[idx]) + } + } + + errs[i] = kzg4844.VerifyCells(cells[i], sidecar.Commitments, cellProofs, custody.Indices()) + } + return errs +} + // validateTx checks whether a transaction is valid according to the consensus // rules and adheres to some heuristic limits of the local node (price and size). -// // This function assumes the static validation has been performed already and // only runs the stateful checks with lock protection. -func (p *BlobPool) validateTx(tx *types.Transaction) error { +// If buffer field is set to true, consider txs in the queue as well. +// This is to prevent fetching cells of invalid transactions, which would be expensive. +func (p *BlobPool) validateTx(tx *types.Transaction, buffer bool) error { + if err := p.ValidateTxBasics(tx); err != nil { + return err + } // Ensure the transaction adheres to the stateful pool filters (nonce, balance) stateOpts := &txpool.ValidationOptionsWithState{ State: p.state, @@ -1292,23 +1413,61 @@ func (p *BlobPool) validateTx(tx *types.Transaction) error { // then handle the error by adding to the buffer. The first gap will // be the next nonce shifted by however many transactions we already // have pooled. - return p.state.GetNonce(addr) + uint64(len(p.index[addr])) + result := p.state.GetNonce(addr) + uint64(len(p.index[addr])) + if buffer { + return result + uint64(len(p.indexQueue[addr])) + } + return result }, UsedAndLeftSlots: func(addr common.Address) (int, int) { have := len(p.index[addr]) + if buffer { + have += len(p.indexQueue[addr]) + } if have >= maxTxsPerAccount { return have, 0 } return have, maxTxsPerAccount - have }, ExistingExpenditure: func(addr common.Address) *big.Int { + result := new(big.Int) if spent := p.spent[addr]; spent != nil { - return spent.ToBig() + result.Add(result, spent.ToBig()) } - return new(big.Int) + + // calculate expenditure after replacements + if buffer { + if replacements := p.replacementQueue[addr]; replacements != nil { + next := p.state.GetNonce(addr) + + for nonce, replacement := range replacements { + if len(p.index[addr]) > int(nonce-next) { + // replacement + originalCost := p.index[addr][nonce-next].costCap + replacementCost := replacement.costCap + + result.Add(result, new(uint256.Int).Sub(replacementCost, originalCost).ToBig()) + } + } + } + + if spentQueue := p.spentQueue[addr]; spentQueue != nil { + result.Add(result, spentQueue.ToBig()) + } + } + + return result }, ExistingCost: func(addr common.Address, nonce uint64) *big.Int { next := p.state.GetNonce(addr) + if buffer { + if p.replacementQueue[addr] != nil && p.replacementQueue[addr][nonce] != nil { + return p.replacementQueue[addr][nonce].costCap.ToBig() + } + if uint64(len(p.indexQueue[addr])) > nonce-next-uint64(len(p.index[addr])) { + return p.indexQueue[addr][nonce-next-uint64(len(p.index[addr]))].costCap.ToBig() + } + } if uint64(len(p.index[addr])) > nonce-next { return p.index[addr][int(nonce-next)].costCap.ToBig() } @@ -1327,37 +1486,58 @@ func (p *BlobPool) validateTx(tx *types.Transaction) error { from, _ = types.Sender(p.signer, tx) // already validated above next = p.state.GetNonce(from) ) - if uint64(len(p.index[from])) > tx.Nonce()-next { - prev := p.index[from][int(tx.Nonce()-next)] - // Ensure the transaction is different than the one tracked locally - if prev.hash == tx.Hash() { - return txpool.ErrAlreadyKnown - } - // Account can support the replacement, but the price bump must also be met - switch { - case tx.GasFeeCapIntCmp(prev.execFeeCap.ToBig()) <= 0: - return fmt.Errorf("%w: new tx gas fee cap %v <= %v queued", txpool.ErrReplaceUnderpriced, tx.GasFeeCap(), prev.execFeeCap) - case tx.GasTipCapIntCmp(prev.execTipCap.ToBig()) <= 0: - return fmt.Errorf("%w: new tx gas tip cap %v <= %v queued", txpool.ErrReplaceUnderpriced, tx.GasTipCap(), prev.execTipCap) - case tx.BlobGasFeeCapIntCmp(prev.blobFeeCap.ToBig()) <= 0: - return fmt.Errorf("%w: new tx blob gas fee cap %v <= %v queued", txpool.ErrReplaceUnderpriced, tx.BlobGasFeeCap(), prev.blobFeeCap) - } - var ( - multiplier = uint256.NewInt(100 + p.config.PriceBump) - onehundred = uint256.NewInt(100) + var prev *blobTxMeta + nonce := tx.Nonce() - minGasFeeCap = new(uint256.Int).Div(new(uint256.Int).Mul(multiplier, prev.execFeeCap), onehundred) - minGasTipCap = new(uint256.Int).Div(new(uint256.Int).Mul(multiplier, prev.execTipCap), onehundred) - minBlobGasFeeCap = new(uint256.Int).Div(new(uint256.Int).Mul(multiplier, prev.blobFeeCap), onehundred) - ) - switch { - case tx.GasFeeCapIntCmp(minGasFeeCap.ToBig()) < 0: - return fmt.Errorf("%w: new tx gas fee cap %v < %v queued + %d%% replacement penalty", txpool.ErrReplaceUnderpriced, tx.GasFeeCap(), prev.execFeeCap, p.config.PriceBump) - case tx.GasTipCapIntCmp(minGasTipCap.ToBig()) < 0: - return fmt.Errorf("%w: new tx gas tip cap %v < %v queued + %d%% replacement penalty", txpool.ErrReplaceUnderpriced, tx.GasTipCap(), prev.execTipCap, p.config.PriceBump) - case tx.BlobGasFeeCapIntCmp(minBlobGasFeeCap.ToBig()) < 0: - return fmt.Errorf("%w: new tx blob gas fee cap %v < %v queued + %d%% replacement penalty", txpool.ErrReplaceUnderpriced, tx.BlobGasFeeCap(), prev.blobFeeCap, p.config.PriceBump) + if nonce < next+uint64(len(p.index[from])) { + // pooled tx + prev = p.index[from][nonce-next] + + // check replacement if it is buffer tx validation + if buffer && p.replacementQueue[from] != nil { + if replacement := p.replacementQueue[from][nonce]; replacement != nil { + prev = replacement + } } + } else if buffer { + offset := nonce - next - uint64(len(p.index[from])) + if uint64(len(p.indexQueue[from])) > offset && offset > 0 { + // buffer tx replacement + prev = p.indexQueue[from][nonce-next-uint64(len(p.index[from]))] + } + } + if prev == nil { + return nil + } + // Ensure the transaction is different than the one tracked locally + if prev.hash == tx.Hash() { + return txpool.ErrAlreadyKnown + } + // Account can support the replacement, but the price bump must also be met + switch { + case tx.GasFeeCapIntCmp(prev.execFeeCap.ToBig()) <= 0: + return fmt.Errorf("%w: new tx gas fee cap %v <= %v queued", txpool.ErrReplaceUnderpriced, tx.GasFeeCap(), prev.execFeeCap) + case tx.GasTipCapIntCmp(prev.execTipCap.ToBig()) <= 0: + return fmt.Errorf("%w: new tx gas tip cap %v <= %v queued", txpool.ErrReplaceUnderpriced, tx.GasTipCap(), prev.execTipCap) + case tx.BlobGasFeeCapIntCmp(prev.blobFeeCap.ToBig()) <= 0: + return fmt.Errorf("%w: new tx blob gas fee cap %v <= %v queued", txpool.ErrReplaceUnderpriced, tx.BlobGasFeeCap(), prev.blobFeeCap) + } + + var ( + multiplier = uint256.NewInt(100 + p.config.PriceBump) + onehundred = uint256.NewInt(100) + + minGasFeeCap = new(uint256.Int).Div(new(uint256.Int).Mul(multiplier, prev.execFeeCap), onehundred) + minGasTipCap = new(uint256.Int).Div(new(uint256.Int).Mul(multiplier, prev.execTipCap), onehundred) + minBlobGasFeeCap = new(uint256.Int).Div(new(uint256.Int).Mul(multiplier, prev.blobFeeCap), onehundred) + ) + switch { + case tx.GasFeeCapIntCmp(minGasFeeCap.ToBig()) < 0: + return fmt.Errorf("%w: new tx gas fee cap %v < %v queued + %d%% replacement penalty", txpool.ErrReplaceUnderpriced, tx.GasFeeCap(), prev.execFeeCap, p.config.PriceBump) + case tx.GasTipCapIntCmp(minGasTipCap.ToBig()) < 0: + return fmt.Errorf("%w: new tx gas tip cap %v < %v queued + %d%% replacement penalty", txpool.ErrReplaceUnderpriced, tx.GasTipCap(), prev.execTipCap, p.config.PriceBump) + case tx.BlobGasFeeCapIntCmp(minBlobGasFeeCap.ToBig()) < 0: + return fmt.Errorf("%w: new tx blob gas fee cap %v < %v queued + %d%% replacement penalty", txpool.ErrReplaceUnderpriced, tx.BlobGasFeeCap(), prev.blobFeeCap, p.config.PriceBump) } return nil } @@ -1368,11 +1548,16 @@ func (p *BlobPool) Has(hash common.Hash) bool { p.lock.RLock() defer p.lock.RUnlock() + if p.lookup.exists(hash) || p.queue[hash] != nil { + return true + } + poolHas := p.lookup.exists(hash) _, gapped := p.gappedSource[hash] return poolHas || gapped } +// getRLP returns the raw RLP-encoded pooledBlobTx data from the store. func (p *BlobPool) getRLP(hash common.Hash) []byte { // Track the amount of time waiting to retrieve a fully resolved blob tx from // the pool and the amount of time actually spent on pulling the data from disk. @@ -1404,20 +1589,48 @@ func (p *BlobPool) Get(hash common.Hash) *types.Transaction { if len(data) == 0 { return nil } - item := new(types.Transaction) - if err := rlp.DecodeBytes(data, item); err != nil { - id, _ := p.lookup.storeidOfTx(hash) - - log.Error("Blobs corrupted for traced transaction", - "hash", hash, "id", id, "err", err) + var pooledTx pooledBlobTx + if err := rlp.DecodeBytes(data, &pooledTx); err != nil { + log.Error("Blobs corrupted for traced transaction", "hash", hash, "err", err) return nil } - return item + tx, err := pooledTx.convert() + if err != nil { + log.Error("Failed to convert transaction in blobpool", "hash", hash, "err", err) + return nil + } + return tx } -// GetRLP returns a RLP-encoded transaction if it is contained in the pool. -func (p *BlobPool) GetRLP(hash common.Hash) []byte { - return p.getRLP(hash) +// GetRLP returns an RLP-encoded transaction if it is contained in the pool. +// TODO: The pool internally stores pooledBlobTx (cell sidecar format), but callers expect +// types.Transaction RLP. This requires an additional decode-encode step, which is inefficient +// and contradicts the original purpose of this function. +// Possible improvements: Drop eth70 and store the cell and transaction separately. +func (p *BlobPool) GetRLP(hash common.Hash, includeBlob bool) []byte { + data := p.getRLP(hash) + if len(data) == 0 { + return nil + } + var pooledTx pooledBlobTx + if err := rlp.DecodeBytes(data, &pooledTx); err != nil { + log.Error("Failed to decode transaction in blobpool", "hash", hash, "err", err) + return nil + } + tx, err := pooledTx.convert() + if err != nil { + log.Error("Failed to convert transaction in blobpool", "hash", hash, "err", err) + return nil + } + if !includeBlob { + tx = tx.WithoutBlob() + } + encoded, err := rlp.EncodeToBytes(tx) + if err != nil { + log.Error("Failed to encode transaction in blobpool", "hash", hash, "err", err) + return nil + } + return encoded } // GetMetadata returns the transaction type and transaction size with the @@ -1486,11 +1699,15 @@ func (p *BlobPool) GetBlobs(vhashes []common.Hash, version byte) ([]*kzg4844.Blo } // Decode the blob transaction - tx := new(types.Transaction) - if err := rlp.DecodeBytes(data, tx); err != nil { + var pooledTx pooledBlobTx + if err := rlp.DecodeBytes(data, &pooledTx); err != nil { log.Error("Blobs corrupted for traced transaction", "id", txID, "err", err) continue } + tx, err := pooledTx.convert() + if err != nil { + return nil, nil, nil, err + } sidecar := tx.BlobTxSidecar() if sidecar == nil { log.Error("Blob tx without sidecar", "hash", tx.Hash(), "id", txID) @@ -1554,14 +1771,115 @@ func (p *BlobPool) Add(txs []*types.Transaction, sync bool) []error { if errs[i] = p.ValidateTxBasics(tx); errs[i] != nil { continue } - errs[i] = p.add(tx) + if len(tx.BlobTxSidecar().Blobs) != 0 { + // from user: convert to pooledBlobTx and add + pooledTx, err := newPooledBlobTx(tx) + if err != nil { + errs[i] = err + continue + } + errs[i] = p.add(pooledTx) + } else { + // from p2p, buffer until the corresponding cells arrive + errs[i] = p.addBuffer(tx) + } } return errs } +func (p *BlobPool) addBuffer(tx *types.Transaction) (err error) { + p.lock.Lock() + defer p.lock.Unlock() + + if cells, ok := p.cellQueue[tx.Hash()]; ok { + sidecar := tx.BlobTxSidecar() + + var cellSidecar types.BlobTxCellSidecar + if len(cells) >= kzg4844.DataPerBlob { + blob, err := kzg4844.RecoverBlobs(cells, p.custodyQueue[tx.Hash()].Indices()) + if err != nil { + return err + } + extendedCells, err := kzg4844.ComputeCells(blob) + if err != nil { + return err + } + cellSidecar = types.BlobTxCellSidecar{ + Version: sidecar.Version, + Cells: extendedCells, + Commitments: sidecar.Commitments, + Proofs: sidecar.Proofs, + Custody: *types.CustodyBitmapAll, + } + } else { + cellSidecar = types.BlobTxCellSidecar{ + Version: sidecar.Version, + Cells: cells, + Commitments: sidecar.Commitments, + Proofs: sidecar.Proofs, + Custody: *p.custodyQueue[tx.Hash()], + } + } + + err := p.addLocked(&pooledBlobTx{Transaction: tx.WithoutBlobTxSidecar(), Sidecar: &cellSidecar, Size: tx.Size()}, true) + if err == nil { + delete(p.cellQueue, tx.Hash()) + delete(p.custodyQueue, tx.Hash()) + } + return err + } + + if err := p.validateTx(tx, true); err != nil { + return err + } + p.queue[tx.Hash()] = tx + from, _ := types.Sender(p.signer, tx) + + next := p.state.GetNonce(from) + nonce := tx.Nonce() + pooledCount := uint64(len(p.index[from])) + meta := newBlobTxMeta(0, tx.Size(), 0, &pooledBlobTx{Transaction: tx, Size: tx.Size()}) + + if nonce < next+pooledCount { + // Pooled transaction replacements are stored in replacementQueue for expenditure validation + // for future transactions from the same account. This overestimates expenditure considering + // that replacement transaction payload fetch may fail and the tx can be dropped. + // However, this conservative approach prevents transactions that passed validation when + // entering the buffer from failing expenditure validation due to transaction replacements. + if p.replacementQueue[from] == nil { + p.replacementQueue[from] = make(map[uint64]*blobTxMeta) + } + if existingReplacement := p.replacementQueue[from][nonce]; existingReplacement != nil { + delete(p.queue, existingReplacement.hash) + } + p.replacementQueue[from][nonce] = meta + } else { + if p.spentQueue[from] == nil { + p.spentQueue[from] = new(uint256.Int) + } + bufferOffset := int(nonce - (next + pooledCount)) + if len(p.indexQueue[from]) > bufferOffset { + // Replace buffer transaction + prev := p.indexQueue[from][bufferOffset] + + delete(p.queue, prev.hash) + + p.indexQueue[from][bufferOffset] = meta + p.spentQueue[from] = new(uint256.Int).Sub(p.spentQueue[from], prev.costCap) + p.spentQueue[from] = new(uint256.Int).Add(p.spentQueue[from], meta.costCap) + + dropReplacedMeter.Mark(1) + } else { + p.indexQueue[from] = append(p.indexQueue[from], meta) + p.spentQueue[from] = new(uint256.Int).Add(p.spentQueue[from], meta.costCap) + } + } + return nil +} + // add inserts a new blob transaction into the pool if it passes validation (both // consensus validity and pool restrictions). -func (p *BlobPool) add(tx *types.Transaction) (err error) { +func (p *BlobPool) add(pooledTx *pooledBlobTx) (err error) { // The blob pool blocks on adding a transaction. This is because blob txs are // only even pulled from the network, so this method will act as the overload // protection for fetches. @@ -1574,15 +1892,18 @@ func (p *BlobPool) add(tx *types.Transaction) (err error) { addtimeHist.Update(time.Since(start).Nanoseconds()) }(time.Now()) - return p.addLocked(tx, true) + return p.addLocked(pooledTx, true) } // addLocked inserts a new blob transaction into the pool if it passes validation (both // consensus validity and pool restrictions). It must be called with the pool lock held. // Only for internal use. -func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error) { +func (p *BlobPool) addLocked(pooledTx *pooledBlobTx, checkGapped bool) (err error) { + tx := pooledTx.Transaction + cellSidecar := pooledTx.Sidecar + // Ensure the transaction is valid from all perspectives - if err := p.validateTx(tx); err != nil { + if err := p.validateTx(tx, false); err != nil { log.Trace("Transaction validation failed", "hash", tx.Hash(), "err", err) switch { case errors.Is(err, txpool.ErrUnderpriced): @@ -1593,11 +1914,11 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error addStaleMeter.Mark(1) case errors.Is(err, core.ErrNonceTooHigh): addGappedMeter.Mark(1) - // Store the tx in memory, and revalidate later + // Store the tx in memory as pooledBlobTx, and revalidate later from, _ := types.Sender(p.signer, tx) allowance := p.gappedAllowance(from) if allowance >= 1 && len(p.gapped) < maxGapped { - p.gapped[from] = append(p.gapped[from], tx) + p.gapped[from] = append(p.gapped[from], pooledTx) p.gappedSource[tx.Hash()] = from log.Trace("added tx to gapped blob queue", "allowance", allowance, "hash", tx.Hash(), "from", from, "nonce", tx.Nonce(), "qlen", len(p.gapped[from])) return nil @@ -1619,6 +1940,13 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error } return err } + if err := txpool.ValidateBlobSidecar(tx, cellSidecar, p.head.Load(), &txpool.ValidationOptions{ + Config: p.chain.Config(), + MaxBlobCount: maxBlobsPerTx, + }); err != nil { + log.Trace("Sidecar validation failed", "hash", tx.Hash(), "err", err) + return err + } // 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 @@ -1627,6 +1955,7 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error addNonExclusiveMeter.Mark(1) return err } + //todo release when evicted from the buffer defer func() { // If the transaction is rejected by some post-validation check, remove // the lock on the reservation set. @@ -1641,7 +1970,7 @@ 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 - blob, err := rlp.EncodeToBytes(tx) + blob, err := rlp.EncodeToBytes(pooledTx) if err != nil { log.Error("Failed to encode transaction for storage", "hash", tx.Hash(), "err", err) return err @@ -1650,7 +1979,7 @@ 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) + meta := newBlobTxMeta(id, pooledTx.Size, p.store.Size(id), pooledTx) var ( next = p.state.GetNonce(from) @@ -1761,13 +2090,13 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error // We have to add in nonce order, but we want to stable sort to cater for situations // where transactions are replaced, keeping the original receive order for same nonce sort.SliceStable(gtxs, func(i, j int) bool { - return gtxs[i].Nonce() < gtxs[j].Nonce() + return gtxs[i].Transaction.Nonce() < gtxs[j].Transaction.Nonce() }) for len(gtxs) > 0 { stateNonce := p.state.GetNonce(from) firstgap := stateNonce + uint64(len(p.index[from])) - if gtxs[0].Nonce() > firstgap { + if gtxs[0].Transaction.Nonce() > firstgap { // Anything beyond the first gap is not addable yet break } @@ -1775,25 +2104,25 @@ func (p *BlobPool) addLocked(tx *types.Transaction, checkGapped bool) (err error // Drop any buffered transactions that became stale in the meantime (included in chain or replaced) // If we arrive to the transaction in the pending range (between the state Nonce and first gap, we // try to add them now while removing from here. - tx := gtxs[0] + ptx := gtxs[0] gtxs[0] = nil gtxs = gtxs[1:] - delete(p.gappedSource, tx.Hash()) + delete(p.gappedSource, ptx.Transaction.Hash()) - if tx.Nonce() < stateNonce { + if ptx.Transaction.Nonce() < stateNonce { // Stale, drop it. Eventually we could add to limbo here if hash matches. - log.Trace("Gapped blob transaction became stale", "hash", tx.Hash(), "from", from, "nonce", tx.Nonce(), "state", stateNonce, "qlen", len(p.gapped[from])) + log.Trace("Gapped blob transaction became stale", "hash", ptx.Transaction.Hash(), "from", from, "nonce", ptx.Transaction.Nonce(), "state", stateNonce, "qlen", len(p.gapped[from])) continue } - if tx.Nonce() <= firstgap { + if ptx.Transaction.Nonce() <= firstgap { // If we hit the pending range, including the first gap, add it and continue to try to add more. // We do not recurse here, but continue to loop instead. // We are under lock, so we can add the transaction directly. - if err := p.addLocked(tx, false); err == nil { - log.Trace("Gapped blob transaction added to pool", "hash", tx.Hash(), "from", from, "nonce", tx.Nonce(), "qlen", len(p.gapped[from])) + if err := p.addLocked(ptx, false); err == nil { + log.Trace("Gapped blob transaction added to pool", "hash", ptx.Transaction.Hash(), "from", from, "nonce", ptx.Transaction.Nonce(), "qlen", len(p.gapped[from])) } else { - log.Trace("Gapped blob transaction not accepted", "hash", tx.Hash(), "from", from, "nonce", tx.Nonce(), "err", err) + log.Trace("Gapped blob transaction not accepted", "hash", ptx.Transaction.Hash(), "from", from, "nonce", ptx.Transaction.Nonce(), "err", err) } } } @@ -2060,10 +2389,10 @@ func (p *BlobPool) evictGapped() { // and we overwrite the slice for this account after filtering. keep := txs[:0] for i, gtx := range txs { - if gtx.Time().Before(cutoff) || gtx.Nonce() < nonce { + if gtx.Transaction.Time().Before(cutoff) || gtx.Transaction.Nonce() < nonce { // Evict old or stale transactions // Should we add stale to limbo here if it would belong? - delete(p.gappedSource, gtx.Hash()) + delete(p.gappedSource, gtx.Transaction.Hash()) txs[i] = nil // Explicitly nil out evicted element } else { keep = append(keep, gtx) @@ -2126,6 +2455,9 @@ func (p *BlobPool) Status(hash common.Hash) txpool.TxStatus { if p.lookup.exists(hash) { return txpool.TxStatusPending } + if _, ok := p.queue[hash]; ok { + return txpool.TxStatusQueued + } if _, gapped := p.gappedSource[hash]; gapped { return txpool.TxStatusQueued } @@ -2180,7 +2512,7 @@ func (p *BlobPool) Clear() { // Reset counters and the gapped buffer p.stored = 0 - p.gapped = make(map[common.Address][]*types.Transaction) + p.gapped = make(map[common.Address][]*pooledBlobTx) p.gappedSource = make(map[common.Hash]common.Address) var ( @@ -2189,3 +2521,119 @@ func (p *BlobPool) Clear() { ) p.evict = newPriceHeap(basefee, blobfee, p.index) } + +// GetCustody returns the custody bitmap for a given transaction hash. +func (p *BlobPool) GetCustody(hash common.Hash) *types.CustodyBitmap { + p.lock.RLock() + defer p.lock.RUnlock() + if meta := p.lookup.txIndex[hash]; meta != nil { + return &meta.custody + } + return nil +} + +// GetCells returns the cells matching the given custody bitmap for a transaction. +func (p *BlobPool) GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) { + p.lock.RLock() + defer p.lock.RUnlock() + id, ok := p.lookup.storeidOfTx(hash) + if !ok { + return nil, errors.New("requested cells don't exist") + } + data, err := p.store.Get(id) + if err != nil { + return nil, errors.New("tracked blob transaction missing from store") + } + // Decode the blob transaction + var pooledTx pooledBlobTx + if err := rlp.DecodeBytes(data, &pooledTx); err != nil { + return nil, errors.New("blobs corrupted for traced transaction") + } + tx := pooledTx.Transaction + sidecar := pooledTx.Sidecar + cells := make([]kzg4844.Cell, 0, mask.OneCount()*len(tx.BlobHashes())) + for cellIdx, custodyIdx := range sidecar.Custody.Indices() { + if mask.IsSet(custodyIdx) { + for blobIdx := 0; blobIdx < len(tx.BlobHashes()); blobIdx++ { + idx := blobIdx*sidecar.Custody.OneCount() + cellIdx + cells = append(cells, sidecar.Cells[idx]) + } + } + } + if len(cells) != mask.OneCount()*len(tx.BlobHashes()) { + return nil, fmt.Errorf("not enough cells: tx %s, needed %d, have %d", tx.Hash(), len(tx.BlobHashes())*mask.OneCount(), len(cells)) + } + return cells, nil +} + +// AddPayload adds cell payloads for blob transactions. +func (p *BlobPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { + p.lock.Lock() + defer p.lock.Unlock() + errs := make([]error, len(txs)) + for i, hash := range txs { + if _, ok := p.queue[hash]; !ok { + p.cellQueue[hash] = cells[i] + p.custodyQueue[hash] = custody + continue + } + + sidecar := p.queue[hash].BlobTxSidecar() + + var cellSidecar types.BlobTxCellSidecar + if len(cells[i]) >= kzg4844.DataPerBlob { + blob, err := kzg4844.RecoverBlobs(cells[i], custody.Indices()) + if err != nil { + errs[i] = err + continue + } + extendedCells, err := kzg4844.ComputeCells(blob) + if err != nil { + errs[i] = err + continue + } + cellSidecar = types.BlobTxCellSidecar{ + Version: sidecar.Version, + Cells: extendedCells, + Commitments: sidecar.Commitments, + Proofs: sidecar.Proofs, + Custody: *types.CustodyBitmapAll, + } + } else { + cellSidecar = types.BlobTxCellSidecar{ + Version: sidecar.Version, + Cells: cells[i], + Commitments: sidecar.Commitments, + Proofs: sidecar.Proofs, + Custody: *custody, + } + } + + errs[i] = p.addLocked(&pooledBlobTx{Transaction: p.queue[hash].WithoutBlobTxSidecar(), Sidecar: &cellSidecar, Size: p.queue[hash].Size()}, true) + // todo nonce gap + + // clean up queues + tx := p.queue[hash] + delete(p.queue, hash) + from, _ := types.Sender(p.signer, tx) + nonce := tx.Nonce() + next := p.state.GetNonce(from) + + if p.replacementQueue[from] != nil { + delete(p.replacementQueue[from], nonce) + if len(p.replacementQueue[from]) == 0 { + delete(p.replacementQueue, from) + } + continue + } + + // plain tx + offset := int(nonce - next - uint64(len(p.index[from]))) + if offset > 0 && offset < len(p.indexQueue[from]) { + removed := p.indexQueue[from][offset] + p.indexQueue[from] = append(p.indexQueue[from][:offset], p.indexQueue[from][offset+1:]...) + p.spentQueue[from] = new(uint256.Int).Sub(p.spentQueue[from], removed.costCap) + } + } + return errs +} diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index ba96bea8ed..41d1bbe383 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -486,7 +486,7 @@ func verifyBlobRetrievals(t *testing.T, pool *BlobPool) { // - 8. Fully duplicate transactions (matching hash) must be dropped // - 9. Duplicate nonces from the same account must be dropped func TestOpenDrops(t *testing.T) { - //log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelTrace, true))) + // log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelTrace, true))) // Create a temporary folder for the persistent backend storage := t.TempDir() @@ -513,75 +513,76 @@ func TestOpenDrops(t *testing.T) { S: new(uint256.Int), }) blob, _ := rlp.EncodeToBytes(tx) - badsig, _ := store.Put(blob) + badsig := tx.Hash() + store.Put(blob) // Insert a sequence of transactions with a nonce gap in between to verify // that anything gapped will get evicted (case 3). var ( gapper, _ = crypto.GenerateKey() - valids = make(map[uint64]struct{}) - gapped = make(map[uint64]struct{}) + valids = make(map[common.Hash]struct{}) + gapped = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{0, 1, 3, 4, 6, 7} { // first gap at #2, another at #5 tx := makeTx(nonce, 1, 1, 1, gapper) blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) + store.Put(blob) if nonce < 2 { - valids[id] = struct{}{} + valids[tx.Hash()] = struct{}{} } else { - gapped[id] = struct{}{} + gapped[tx.Hash()] = struct{}{} } } // Insert a sequence of transactions with a gapped starting nonce to verify // that the entire set will get dropped (case 3). var ( dangler, _ = crypto.GenerateKey() - dangling = make(map[uint64]struct{}) + dangling = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{1, 2, 3} { // first gap at #0, all set dangling tx := makeTx(nonce, 1, 1, 1, dangler) blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) - dangling[id] = struct{}{} + store.Put(blob) + dangling[tx.Hash()] = struct{}{} } // Insert a sequence of transactions with already passed nonces to veirfy // that the entire set will get dropped (case 4). var ( filler, _ = crypto.GenerateKey() - filled = make(map[uint64]struct{}) + filled = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{0, 1, 2} { // account nonce at 3, all set filled tx := makeTx(nonce, 1, 1, 1, filler) blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) - filled[id] = struct{}{} + store.Put(blob) + filled[tx.Hash()] = struct{}{} } // Insert a sequence of transactions with partially passed nonces to verify // that the included part of the set will get dropped (case 4). var ( overlapper, _ = crypto.GenerateKey() - overlapped = make(map[uint64]struct{}) + overlapped = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{0, 1, 2, 3} { // account nonce at 2, half filled tx := makeTx(nonce, 1, 1, 1, overlapper) blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) + store.Put(blob) if nonce >= 2 { - valids[id] = struct{}{} + valids[tx.Hash()] = struct{}{} } else { - overlapped[id] = struct{}{} + overlapped[tx.Hash()] = struct{}{} } } // Insert a sequence of transactions with an underpriced first to verify that // the entire set will get dropped (case 5). var ( underpayer, _ = crypto.GenerateKey() - underpaid = make(map[uint64]struct{}) + underpaid = make(map[common.Hash]struct{}) ) for i := 0; i < 5; i++ { // make #0 underpriced var tx *types.Transaction @@ -592,15 +593,15 @@ func TestOpenDrops(t *testing.T) { } blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) - underpaid[id] = struct{}{} + store.Put(blob) + underpaid[tx.Hash()] = struct{}{} } // Insert a sequence of transactions with an underpriced in between to verify // that it and anything newly gapped will get evicted (case 5). var ( outpricer, _ = crypto.GenerateKey() - outpriced = make(map[uint64]struct{}) + outpriced = make(map[common.Hash]struct{}) ) for i := 0; i < 5; i++ { // make #2 underpriced var tx *types.Transaction @@ -611,18 +612,18 @@ func TestOpenDrops(t *testing.T) { } blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) + store.Put(blob) if i < 2 { - valids[id] = struct{}{} + valids[tx.Hash()] = struct{}{} } else { - outpriced[id] = struct{}{} + outpriced[tx.Hash()] = struct{}{} } } // Insert a sequence of transactions fully overdrafted to verify that the // entire set will get invalidated (case 6). var ( exceeder, _ = crypto.GenerateKey() - exceeded = make(map[uint64]struct{}) + exceeded = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{0, 1, 2} { // nonce 0 overdrafts the account var tx *types.Transaction @@ -633,14 +634,14 @@ func TestOpenDrops(t *testing.T) { } blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) - exceeded[id] = struct{}{} + store.Put(blob) + exceeded[tx.Hash()] = struct{}{} } // Insert a sequence of transactions partially overdrafted to verify that part // of the set will get invalidated (case 6). var ( overdrafter, _ = crypto.GenerateKey() - overdrafted = make(map[uint64]struct{}) + overdrafted = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{0, 1, 2} { // nonce 1 overdrafts the account var tx *types.Transaction @@ -651,44 +652,46 @@ func TestOpenDrops(t *testing.T) { } blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) + store.Put(blob) if nonce < 1 { - valids[id] = struct{}{} + valids[tx.Hash()] = struct{}{} } else { - overdrafted[id] = struct{}{} + overdrafted[tx.Hash()] = struct{}{} } } // Insert a sequence of transactions overflowing the account cap to verify // that part of the set will get invalidated (case 7). var ( overcapper, _ = crypto.GenerateKey() - overcapped = make(map[uint64]struct{}) + overcapped = make(map[common.Hash]struct{}) ) for nonce := uint64(0); nonce < maxTxsPerAccount+3; nonce++ { - blob, _ := rlp.EncodeToBytes(makeTx(nonce, 1, 1, 1, overcapper)) + tx := makeTx(nonce, 1, 1, 1, overcapper) + blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) + store.Put(blob) if nonce < maxTxsPerAccount { - valids[id] = struct{}{} + valids[tx.Hash()] = struct{}{} } else { - overcapped[id] = struct{}{} + overcapped[tx.Hash()] = struct{}{} } } // Insert a batch of duplicated transactions to verify that only one of each // version will remain (case 8). var ( duplicater, _ = crypto.GenerateKey() - duplicated = make(map[uint64]struct{}) + duplicated = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{0, 1, 2} { - blob, _ := rlp.EncodeToBytes(makeTx(nonce, 1, 1, 1, duplicater)) + tx := makeTx(nonce, 1, 1, 1, duplicater) + blob, _ := rlp.EncodeToBytes(tx) for i := 0; i < int(nonce)+1; i++ { - id, _ := store.Put(blob) + store.Put(blob) if i == 0 { - valids[id] = struct{}{} + valids[tx.Hash()] = struct{}{} } else { - duplicated[id] = struct{}{} + duplicated[tx.Hash()] = struct{}{} } } } @@ -696,17 +699,18 @@ func TestOpenDrops(t *testing.T) { // remain (case 9). var ( repeater, _ = crypto.GenerateKey() - repeated = make(map[uint64]struct{}) + repeated = make(map[common.Hash]struct{}) ) for _, nonce := range []uint64{0, 1, 2} { for i := 0; i < int(nonce)+1; i++ { - blob, _ := rlp.EncodeToBytes(makeTx(nonce, 1, uint64(i)+1 /* unique hashes */, 1, repeater)) + tx := makeTx(nonce, 1, uint64(i)+1 /* unique hashes */, 1, repeater) + blob, _ := rlp.EncodeToBytes(tx) - id, _ := store.Put(blob) + store.Put(blob) if i == 0 { - valids[id] = struct{}{} + valids[tx.Hash()] = struct{}{} } else { - repeated[id] = struct{}{} + repeated[tx.Hash()] = struct{}{} } } } @@ -743,39 +747,41 @@ func TestOpenDrops(t *testing.T) { // Verify that the malformed (case 1), badly signed (case 2) and gapped (case // 3) txs have been deleted from the pool - alive := make(map[uint64]struct{}) + alive := make(map[common.Hash]struct{}) for _, txs := range pool.index { for _, tx := range txs { switch tx.id { case malformed: t.Errorf("malformed RLP transaction remained in storage") - case badsig: - t.Errorf("invalidly signed transaction remained in storage") default: - if _, ok := dangling[tx.id]; ok { + if badsig == tx.hash { + t.Errorf("invalidly signed transaction remained in storage") + } + if _, ok := dangling[tx.hash]; ok { t.Errorf("dangling transaction remained in storage: %d", tx.id) - } else if _, ok := filled[tx.id]; ok { + } else if _, ok := filled[tx.hash]; ok { t.Errorf("filled transaction remained in storage: %d", tx.id) - } else if _, ok := overlapped[tx.id]; ok { + } else if _, ok := overlapped[tx.hash]; ok { t.Errorf("overlapped transaction remained in storage: %d", tx.id) - } else if _, ok := gapped[tx.id]; ok { + } else if _, ok := gapped[tx.hash]; ok { t.Errorf("gapped transaction remained in storage: %d", tx.id) - } else if _, ok := underpaid[tx.id]; ok { + } else if _, ok := underpaid[tx.hash]; ok { t.Errorf("underpaid transaction remained in storage: %d", tx.id) - } else if _, ok := outpriced[tx.id]; ok { + } else if _, ok := outpriced[tx.hash]; ok { t.Errorf("outpriced transaction remained in storage: %d", tx.id) - } else if _, ok := exceeded[tx.id]; ok { + } else if _, ok := exceeded[tx.hash]; ok { t.Errorf("fully overdrafted transaction remained in storage: %d", tx.id) - } else if _, ok := overdrafted[tx.id]; ok { + } else if _, ok := overdrafted[tx.hash]; ok { t.Errorf("partially overdrafted transaction remained in storage: %d", tx.id) - } else if _, ok := overcapped[tx.id]; ok { + } else if _, ok := overcapped[tx.hash]; ok { t.Errorf("overcapped transaction remained in storage: %d", tx.id) - } else if _, ok := duplicated[tx.id]; ok { - t.Errorf("duplicated transaction remained in storage: %d", tx.id) - } else if _, ok := repeated[tx.id]; ok { + } else if _, ok := repeated[tx.hash]; ok { t.Errorf("repeated nonce transaction remained in storage: %d", tx.id) } else { - alive[tx.id] = struct{}{} + if _, ok := alive[tx.hash]; ok { + t.Errorf("duplicated transaction remained in storage: %d", tx.id) + } + alive[tx.hash] = struct{}{} } } } @@ -784,14 +790,14 @@ func TestOpenDrops(t *testing.T) { if len(alive) != len(valids) { t.Errorf("valid transaction count mismatch: have %d, want %d", len(alive), len(valids)) } - for id := range alive { - if _, ok := valids[id]; !ok { - t.Errorf("extra transaction %d", id) + for hash := range alive { + if _, ok := valids[hash]; !ok { + t.Errorf("extra transaction %s", hash) } } - for id := range valids { - if _, ok := alive[id]; !ok { - t.Errorf("missing transaction %d", id) + for hash := range valids { + if _, ok := alive[hash]; !ok { + t.Errorf("missing transaction %s", hash) } } // Verify all the calculated pool internals. Interestingly, this is **not** @@ -1010,7 +1016,10 @@ func TestOpenCap(t *testing.T) { keep = []common.Address{addr1, addr3} drop = []common.Address{addr2} - size = 2 * (txAvgSize + blobSize + uint64(txBlobOverhead)) + // After migration to pooledBlobTx, cells (128 x 2048 = 2*blobSize) replace blobs. + // The actual billy slot size for pooledBlobTx is 2*(blobSize+txBlobOverhead)+txAvgSize. + pooledSlotSize uint64 = 2*(blobSize+uint64(txBlobOverhead)) + txAvgSize + size = 2 * pooledSlotSize ) store.Put(blob1) store.Put(blob2) @@ -1019,7 +1028,7 @@ func TestOpenCap(t *testing.T) { // Verify pool capping twice: first by reducing the data cap, then restarting // with a high cap to ensure everything was persisted previously - for _, datacap := range []uint64{2 * (txAvgSize + blobSize + uint64(txBlobOverhead)), 1000 * (txAvgSize + blobSize + uint64(txBlobOverhead))} { + for _, datacap := range []uint64{size, 1000 * pooledSlotSize} { // Create a blob pool out of the pre-seeded data, but cap it to 2 blob transaction statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) statedb.AddBalance(addr1, uint256.NewInt(1_000_000_000), tracing.BalanceChangeUnspecified) @@ -1325,7 +1334,7 @@ func TestBlobCountLimit(t *testing.T) { // Check that first succeeds second fails. if errs[0] != nil { - t.Fatalf("expected tx with 7 blobs to succeed, got %v", errs[0]) + t.Fatalf("expected tx with 7 blobs to succeed, got: %v", errs[0]) } if !errors.Is(errs[1], txpool.ErrTxBlobLimitExceeded) { t.Fatalf("expected tx with 8 blobs to fail, got: %v", errs[1]) @@ -2112,7 +2121,8 @@ func benchmarkPoolPending(b *testing.B, datacap uint64) { b.Fatal(err) } statedb.AddBalance(addr, uint256.NewInt(1_000_000_000), tracing.BalanceChangeUnspecified) - pool.add(tx) + pooledTx, _ := newPooledBlobTx(tx) + pool.add(pooledTx) } statedb.Commit(0, true, false) defer pool.Close() @@ -2133,3 +2143,117 @@ func benchmarkPoolPending(b *testing.B, datacap uint64) { } } } + +func TestGetCells(t *testing.T) { + storage := t.TempDir() + + os.MkdirAll(filepath.Join(storage, pendingTransactionStore), 0700) + store, _ := billy.Open(billy.Options{Path: filepath.Join(storage, pendingTransactionStore)}, newSlotter(params.BlobTxMaxBlobs), nil) + + var ( + key1, _ = crypto.GenerateKey() + + addr1 = crypto.PubkeyToAddress(key1.PublicKey) + + tx1 = makeMultiBlobTx(0, 1, 1000, 100, 3, 0, key1, types.BlobSidecarVersion1) // blobs [0, 3) + + pooledTx1, _ = newPooledBlobTx(tx1) + + blob1, _ = rlp.EncodeToBytes(pooledTx1) + ) + + store.Put(blob1) + store.Close() + + statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + statedb.AddBalance(addr1, uint256.NewInt(1_000_000_000), tracing.BalanceChangeUnspecified) + statedb.Commit(0, true, false) + + chain := &testBlockChain{ + config: params.MainnetChainConfig, + basefee: uint256.NewInt(params.InitialBaseFee), + blobfee: uint256.NewInt(params.BlobTxMinBlobGasprice), + statedb: statedb, + } + pool := New(Config{Datadir: storage}, chain, nil) + if err := pool.Init(1, chain.CurrentBlock(), newReserver()); err != nil { + t.Fatalf("failed to create blob pool: %v", err) + } + defer pool.Close() + + tests := []struct { + name string + hash common.Hash + mask types.CustodyBitmap + expectedLen int + shouldFail bool + }{ + { + name: "Get cells with single index", + hash: tx1.Hash(), + mask: types.NewCustodyBitmap([]uint64{5}), + expectedLen: 3, + shouldFail: false, + }, + { + name: "Get cells with all indices", + hash: tx1.Hash(), + mask: *types.CustodyBitmapAll, + expectedLen: 384, + shouldFail: false, + }, + { + name: "Get cells with no indices", + hash: tx1.Hash(), + mask: types.CustodyBitmap{}, + expectedLen: 0, + shouldFail: false, + }, + { + name: "Get cells for non-existent transaction", + hash: common.Hash{0x01, 0x02, 0x03}, + mask: types.NewCustodyBitmap([]uint64{0, 1}), + expectedLen: 0, + shouldFail: true, + }, + { + name: "Get cells with random indices", + hash: tx1.Hash(), + mask: types.NewRandomCustodyBitmap(8), + expectedLen: 24, + shouldFail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cells, err := pool.GetCells(tt.hash, tt.mask) + + if err != nil && !tt.shouldFail { + t.Errorf("expected to success, got %v", err) + } + if err == nil && tt.shouldFail { + t.Errorf("expected to fail, got %v", err) + } + + if len(cells) != tt.expectedLen { + t.Errorf("expected %d cells, got %d", tt.expectedLen, len(cells)) + } + + if tt.expectedLen > 0 && tt.expectedLen%3 == 0 { + blobCount := 3 + cellsPerBlob := tt.expectedLen / blobCount + + for blobIdx := 0; blobIdx < blobCount; blobIdx++ { + startIdx := blobIdx * cellsPerBlob + endIdx := startIdx + cellsPerBlob + + if endIdx > len(cells) { + t.Errorf("blob %d: expected cells up to index %d, but only have %d cells", + blobIdx, endIdx-1, len(cells)) + } + } + } + }) + } +} diff --git a/core/txpool/blobpool/limbo.go b/core/txpool/blobpool/limbo.go index 36284d6a03..90cd9d4a9d 100644 --- a/core/txpool/blobpool/limbo.go +++ b/core/txpool/blobpool/limbo.go @@ -33,7 +33,7 @@ import ( type limboBlob struct { TxHash common.Hash // Owner transaction's hash to support resurrecting reorged txs Block uint64 // Block in which the blob transaction was included - Tx *types.Transaction + Tx *pooledBlobTx } // limbo is a light, indexed database to temporarily store recently included @@ -146,15 +146,15 @@ func (l *limbo) finalize(final *types.Header) { // push stores a new blob transaction into the limbo, waiting until finality for // it to be automatically evicted. -func (l *limbo) push(tx *types.Transaction, block uint64) error { +func (l *limbo) push(tx *pooledBlobTx, block uint64) error { // If the blobs are already tracked by the limbo, consider it a programming // error. There's not much to do against it, but be loud. - if _, ok := l.index[tx.Hash()]; ok { - log.Error("Limbo cannot push already tracked blobs", "tx", tx.Hash()) + if _, ok := l.index[tx.Transaction.Hash()]; ok { + log.Error("Limbo cannot push already tracked blobs", "tx", tx.Transaction.Hash()) return errors.New("already tracked blob transaction") } if err := l.setAndIndex(tx, block); err != nil { - log.Error("Failed to set and index limboed blobs", "tx", tx.Hash(), "err", err) + log.Error("Failed to set and index limboed blobs", "tx", tx.Transaction.Hash(), "err", err) return err } return nil @@ -163,7 +163,7 @@ func (l *limbo) push(tx *types.Transaction, block uint64) error { // pull retrieves a previously pushed set of blobs back from the limbo, removing // it at the same time. This method should be used when a previously included blob // transaction gets reorged out. -func (l *limbo) pull(tx common.Hash) (*types.Transaction, error) { +func (l *limbo) pull(tx common.Hash) (*pooledBlobTx, error) { // If the blobs are not tracked by the limbo, there's not much to do. This // can happen for example if a blob transaction is mined without pushing it // into the network first. @@ -240,8 +240,8 @@ func (l *limbo) getAndDrop(id uint64) (*limboBlob, error) { // setAndIndex assembles a limbo blob database entry and stores it, also updating // the in-memory indices. -func (l *limbo) setAndIndex(tx *types.Transaction, block uint64) error { - txhash := tx.Hash() +func (l *limbo) setAndIndex(tx *pooledBlobTx, block uint64) error { + txhash := tx.Transaction.Hash() item := &limboBlob{ TxHash: txhash, Block: block, diff --git a/core/txpool/blobpool/lookup.go b/core/txpool/blobpool/lookup.go index 7607cd487a..e105d47706 100644 --- a/core/txpool/blobpool/lookup.go +++ b/core/txpool/blobpool/lookup.go @@ -18,11 +18,13 @@ package blobpool import ( "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" ) type txMetadata struct { - id uint64 // the billy id of transction - size uint64 // the RLP encoded size of transaction (blobs are included) + id uint64 // the billy id of transction + size uint64 // the RLP encoded size of transaction (blobs are included) + custody types.CustodyBitmap } // lookup maps blob versioned hashes to transaction hashes that include them, @@ -91,8 +93,9 @@ func (l *lookup) track(tx *blobTxMeta) { } // Map the transaction hash to the datastore id and RLP-encoded transaction size l.txIndex[tx.hash] = &txMetadata{ - id: tx.id, - size: tx.size, + id: tx.id, + size: tx.size, + custody: *tx.custody, } } diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go index 25c4b13166..dd6c539ec6 100644 --- a/core/txpool/legacypool/legacypool.go +++ b/core/txpool/legacypool/legacypool.go @@ -1011,7 +1011,7 @@ func (pool *LegacyPool) get(hash common.Hash) *types.Transaction { } // GetRLP returns a RLP-encoded transaction if it is contained in the pool. -func (pool *LegacyPool) GetRLP(hash common.Hash) []byte { +func (pool *LegacyPool) GetRLP(hash common.Hash, _ bool) []byte { tx := pool.all.Get(hash) if tx == nil { return nil diff --git a/core/txpool/subpool.go b/core/txpool/subpool.go index 4cc1b193d6..dfd0ccead7 100644 --- a/core/txpool/subpool.go +++ b/core/txpool/subpool.go @@ -132,7 +132,8 @@ type SubPool interface { Get(hash common.Hash) *types.Transaction // GetRLP returns a RLP-encoded transaction if it is contained in the pool. - GetRLP(hash common.Hash) []byte + // If includeBlob is false, blob data is stripped from blob transactions (ETH/71). + GetRLP(hash common.Hash, includeBlob bool) []byte // GetMetadata returns the transaction type and transaction size with the // given transaction hash. diff --git a/core/txpool/txpool.go b/core/txpool/txpool.go index 25647e0cce..a9075cfd91 100644 --- a/core/txpool/txpool.go +++ b/core/txpool/txpool.go @@ -284,9 +284,9 @@ func (p *TxPool) Get(hash common.Hash) *types.Transaction { } // GetRLP returns a RLP-encoded transaction if it is contained in the pool. -func (p *TxPool) GetRLP(hash common.Hash) []byte { +func (p *TxPool) GetRLP(hash common.Hash, includeBlob bool) []byte { for _, subpool := range p.subpools { - encoded := subpool.GetRLP(hash) + encoded := subpool.GetRLP(hash, includeBlob) if len(encoded) != 0 { return encoded } diff --git a/core/txpool/validation.go b/core/txpool/validation.go index 13b1bfa312..1769ec3793 100644 --- a/core/txpool/validation.go +++ b/core/txpool/validation.go @@ -64,9 +64,6 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types if opts.Accept&(1< opts.MaxBlobCount { - return fmt.Errorf("%w: blob count %v, limit %v", ErrTxBlobLimitExceeded, blobCount, opts.MaxBlobCount) - } // Before performing any expensive validations, sanity check that the tx is // smaller than the maximum limit the pool can meaningfully handle if tx.Size() > opts.MaxSize { @@ -146,9 +143,6 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types if tx.GasTipCapIntCmp(opts.MinTip) < 0 { return fmt.Errorf("%w: gas tip cap %v, minimum needed %v", ErrTxGasPriceTooLow, tx.GasTipCap(), opts.MinTip) } - if tx.Type() == types.BlobTxType { - return validateBlobTx(tx, head, opts) - } if tx.Type() == types.SetCodeTxType { if len(tx.SetCodeAuthorizations()) == 0 { return errors.New("set code tx must have at least one authorization tuple") @@ -157,14 +151,33 @@ func ValidateTransaction(tx *types.Transaction, head *types.Header, signer types return nil } -// validateBlobTx implements the blob-transaction specific validations. -func validateBlobTx(tx *types.Transaction, head *types.Header, opts *ValidationOptions) error { - sidecar := tx.BlobTxSidecar() - if sidecar == nil { - return errors.New("missing sidecar in blob transaction") +func ValidateBlobSidecar(tx *types.Transaction, sidecar *types.BlobTxCellSidecar, head *types.Header, opts *ValidationOptions) error { + if sidecar.Custody.OneCount() == 0 { + return errors.New("blobless blob transaction") } - // Ensure the sidecar is constructed with the correct version, consistent - // with the current fork. + // Ensure the blob fee cap satisfies the minimum blob gas price + if tx.BlobGasFeeCapIntCmp(blobTxMinBlobGasPrice) < 0 { + return fmt.Errorf("%w: blob fee cap %v, minimum needed %v", ErrTxGasPriceTooLow, tx.BlobGasFeeCap(), blobTxMinBlobGasPrice) + } + // Verify whether the blob count is consistent with other parts of the sidecar and the transaction + blobCount := len(sidecar.Cells) / sidecar.Custody.OneCount() + hashes := tx.BlobHashes() + if blobCount == 0 { + return errors.New("blobless blob transaction") + } + if blobCount != len(sidecar.Commitments) || blobCount != len(hashes) { + return fmt.Errorf("invalid number of %d blobs compared to %d commitments and %d blob hashes", blobCount, len(sidecar.Commitments), len(tx.BlobHashes())) + } + + // Check whether the blob count does not exceed the max blob count + if blobCount > opts.MaxBlobCount { + return fmt.Errorf("%w: blob count %v, limit %v", ErrTxBlobLimitExceeded, blobCount, opts.MaxBlobCount) + } + + if err := sidecar.ValidateBlobCommitmentHashes(hashes); err != nil { + return err + } + // Ensure the sidecar version is correct for the current fork (master: bd77b77ed) version := types.BlobSidecarVersion0 if opts.Config.IsOsaka(head.Number, head.Time) { version = types.BlobSidecarVersion1 @@ -172,50 +185,42 @@ func validateBlobTx(tx *types.Transaction, head *types.Header, opts *ValidationO if sidecar.Version != version { return fmt.Errorf("unexpected sidecar version, want: %d, got: %d", version, sidecar.Version) } - // Ensure the blob fee cap satisfies the minimum blob gas price - if tx.BlobGasFeeCapIntCmp(blobTxMinBlobGasPrice) < 0 { - return fmt.Errorf("%w: blob fee cap %v, minimum needed %v", ErrTxGasPriceTooLow, tx.BlobGasFeeCap(), blobTxMinBlobGasPrice) - } - // Ensure the number of items in the blob transaction and various side - // data match up before doing any expensive validations - hashes := tx.BlobHashes() - if len(hashes) == 0 { - return errors.New("blobless blob transaction") - } - if len(hashes) > params.BlobTxMaxBlobs { - return fmt.Errorf("too many blobs in transaction: have %d, permitted %d", len(hashes), params.BlobTxMaxBlobs) - } - if len(sidecar.Blobs) != len(hashes) { - return fmt.Errorf("invalid number of %d blobs compared to %d blob hashes", len(sidecar.Blobs), len(hashes)) - } - if err := sidecar.ValidateBlobCommitmentHashes(hashes); err != nil { - return err - } // Fork-specific sidecar checks, including proof verification. if sidecar.Version == types.BlobSidecarVersion1 { return validateBlobSidecarOsaka(sidecar, hashes) - } else { - return validateBlobSidecarLegacy(sidecar, hashes) } + return validateBlobSidecarLegacy(sidecar, hashes) } -func validateBlobSidecarLegacy(sidecar *types.BlobTxSidecar, hashes []common.Hash) error { +func validateBlobSidecarLegacy(sidecar *types.BlobTxCellSidecar, hashes []common.Hash) error { if len(sidecar.Proofs) != len(hashes) { return fmt.Errorf("invalid number of %d blob proofs expected %d", len(sidecar.Proofs), len(hashes)) } - for i := range sidecar.Blobs { - if err := kzg4844.VerifyBlobProof(&sidecar.Blobs[i], sidecar.Commitments[i], sidecar.Proofs[i]); err != nil { - return fmt.Errorf("%w: invalid blob proof: %v", ErrKZGVerificationError, err) + blobs, err := kzg4844.RecoverBlobs(sidecar.Cells, sidecar.Custody.Indices()) + if err != nil { + return fmt.Errorf("%w: %v", ErrKZGVerificationError, err) + } + for i := range blobs { + if err := kzg4844.VerifyBlobProof(&blobs[i], sidecar.Commitments[i], sidecar.Proofs[i]); err != nil { + return fmt.Errorf("%w: invalid blob %d: %v", ErrKZGVerificationError, i, err) } } return nil } -func validateBlobSidecarOsaka(sidecar *types.BlobTxSidecar, hashes []common.Hash) error { +func validateBlobSidecarOsaka(sidecar *types.BlobTxCellSidecar, hashes []common.Hash) error { if len(sidecar.Proofs) != len(hashes)*kzg4844.CellProofsPerBlob { return fmt.Errorf("invalid number of %d blob proofs expected %d", len(sidecar.Proofs), len(hashes)*kzg4844.CellProofsPerBlob) } - if err := kzg4844.VerifyCellProofs(sidecar.Blobs, sidecar.Commitments, sidecar.Proofs); err != nil { + indices := sidecar.Custody.Indices() + cellProofs := make([]kzg4844.Proof, 0) + for blobIdx := range len(sidecar.Commitments) { + for _, proofIdx := range indices { + idx := blobIdx*kzg4844.CellProofsPerBlob + int(proofIdx) + cellProofs = append(cellProofs, sidecar.Proofs[idx]) + } + } + if err := kzg4844.VerifyCells(sidecar.Cells, sidecar.Commitments, cellProofs, sidecar.Custody.Indices()); err != nil { return fmt.Errorf("%w: %v", ErrKZGVerificationError, err) } return nil diff --git a/core/types/custody_bitmap.go b/core/types/custody_bitmap.go new file mode 100644 index 0000000000..8e52e093d1 --- /dev/null +++ b/core/types/custody_bitmap.go @@ -0,0 +1,132 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package types + +import ( + "math/bits" + "math/rand" + + "github.com/ethereum/go-ethereum/crypto/kzg4844" +) + +// `CustodyBitmap` is a bitmap to represent which custody index to store (little endian) +type CustodyBitmap [16]byte + +var ( + CustodyBitmapAll = func() *CustodyBitmap { + var result CustodyBitmap + for i := range result { + result[i] = 0xFF + } + return &result + }() + + CustodyBitmapData = func() *CustodyBitmap { + var result CustodyBitmap + for i := 0; i < kzg4844.DataPerBlob/8; i++ { + result[i] = 0xFF + } + return &result + }() +) + +func NewCustodyBitmap(indices []uint64) CustodyBitmap { + var result CustodyBitmap + for _, i := range indices { + if i >= uint64(kzg4844.CellsPerBlob) { + panic("CustodyBitmap: bit index out of range") + } + result[i/8] |= 1 << (i % 8) + } + return result +} + +// NewRandomCustodyBitmap creates a CustodyBitmap with n randomly selected indices. +// This should be used only for tests. +func NewRandomCustodyBitmap(n int) CustodyBitmap { + if n <= 0 || n > kzg4844.CellsPerBlob { + panic("CustodyBitmap: invalid number of indices") + } + indices := make([]uint64, 0, n) + used := make(map[uint64]bool) + for len(indices) < n { + idx := uint64(rand.Intn(kzg4844.CellsPerBlob)) + if !used[idx] { + used[idx] = true + indices = append(indices, idx) + } + } + return NewCustodyBitmap(indices) +} + +// IsSet returns whether bit i is set. +func (b CustodyBitmap) IsSet(i uint64) bool { + if i >= uint64(kzg4844.CellsPerBlob) { + return false + } + return (b[i/8]>>(i%8))&1 == 1 +} + +// OneCount returns the number of bits set to 1. +func (b CustodyBitmap) OneCount() int { + total := 0 + for _, v := range b { + total += bits.OnesCount8(v) + } + return total +} + +// Indices returns the bit positions set to 1, in ascending order. +func (b CustodyBitmap) Indices() []uint64 { + out := make([]uint64, 0, b.OneCount()) + for byteIdx, val := range b { + v := val + for v != 0 { + tz := bits.TrailingZeros8(v) + out = append(out, uint64(byteIdx*8+tz)) + v &^= 1 << tz + } + } + return out +} + +// Difference returns b AND NOT set (bits in b but not in set). +func (b CustodyBitmap) Difference(set *CustodyBitmap) *CustodyBitmap { + var out CustodyBitmap + for i := range b { + out[i] = b[i] &^ set[i] + } + return &out +} + +// Intersection returns b AND set. +func (b CustodyBitmap) Intersection(set *CustodyBitmap) *CustodyBitmap { + var out CustodyBitmap + for i := range b { + out[i] = b[i] & set[i] + } + return &out +} + +// Union returns b OR set. +func (b CustodyBitmap) Union(set *CustodyBitmap) *CustodyBitmap { + var out CustodyBitmap + for i := range b { + out[i] = b[i] | set[i] + } + return &out +} diff --git a/core/types/transaction.go b/core/types/transaction.go index e9bf08daef..4f1a38382d 100644 --- a/core/types/transaction.go +++ b/core/types/transaction.go @@ -510,6 +510,18 @@ func (tx *Transaction) WithoutBlobTxSidecar() *Transaction { return cpy } +// WithoutBlob returns a copy of tx with the blob data removed from the sidecar, +// keeping commitments, proofs and other metadata intact. +func (tx *Transaction) WithoutBlob() *Transaction { + blobtx, ok := tx.inner.(*BlobTx) + if !ok || blobtx.Sidecar == nil { + return tx + } + sidecarWithoutBlob := blobtx.Sidecar.Copy() + sidecarWithoutBlob.Blobs = nil + return tx.WithBlobTxSidecar(sidecarWithoutBlob) +} + // WithBlobTxSidecar returns a copy of tx with the blob sidecar added. func (tx *Transaction) WithBlobTxSidecar(sideCar *BlobTxSidecar) *Transaction { blobtx, ok := tx.inner.(*BlobTx) diff --git a/core/types/tx_blob.go b/core/types/tx_blob.go index 31aadb5419..b941c8876f 100644 --- a/core/types/tx_blob.go +++ b/core/types/tx_blob.go @@ -176,6 +176,37 @@ func (sc *BlobTxSidecar) Copy() *BlobTxSidecar { } } +func (sc *BlobTxSidecar) ToBlobTxCellSidecar() (*BlobTxCellSidecar, error) { + cells, err := kzg4844.ComputeCells(sc.Blobs) + if err != nil { + return nil, err + } + return &BlobTxCellSidecar{ + Version: sc.Version, + Cells: cells, + Commitments: sc.Commitments, + Proofs: sc.Proofs, + Custody: *CustodyBitmapAll, + }, nil +} + +type BlobTxCellSidecar struct { + Version byte + Cells []kzg4844.Cell + Commitments []kzg4844.Commitment + Proofs []kzg4844.Proof + Custody CustodyBitmap +} + +// ValidateBlobCommitmentHashes checks whether the given hashes correspond to the +// commitments in the sidecar +func (c *BlobTxCellSidecar) ValidateBlobCommitmentHashes(hashes []common.Hash) error { + sc := BlobTxSidecar{ + Commitments: c.Commitments, + } + return sc.ValidateBlobCommitmentHashes(hashes) +} + // blobTxWithBlobs represents blob tx with its corresponding sidecar. // This is an interface because sidecars are versioned. type blobTxWithBlobs interface { diff --git a/crypto/kzg4844/kzg4844.go b/crypto/kzg4844/kzg4844.go index 3ccc204838..f99fe593e4 100644 --- a/crypto/kzg4844/kzg4844.go +++ b/crypto/kzg4844/kzg4844.go @@ -34,9 +34,27 @@ var ( blobT = reflect.TypeFor[Blob]() commitmentT = reflect.TypeFor[Commitment]() proofT = reflect.TypeFor[Proof]() + cellT = reflect.TypeFor[Cell]() ) -const CellProofsPerBlob = 128 +const ( + CellProofsPerBlob = 128 + CellsPerBlob = 128 + DataPerBlob = 64 +) + +// Cell represents a single cell in a blob. +type Cell [2048]byte + +// UnmarshalJSON parses a cell in hex syntax. +func (c *Cell) UnmarshalJSON(input []byte) error { + return hexutil.UnmarshalFixedJSON(cellT, input, c[:]) +} + +// MarshalText returns the hex representation of c. +func (c *Cell) MarshalText() ([]byte, error) { + return hexutil.Bytes(c[:]).MarshalText() +} // Blob represents a 4844 data blob. type Blob [131072]byte @@ -189,3 +207,27 @@ func CalcBlobHashV1(hasher hash.Hash, commit *Commitment) (vh [32]byte) { func IsValidVersionedHash(h []byte) bool { return len(h) == 32 && h[0] == 0x01 } + +// VerifyCells verifies a batch of proofs corresponding to the cells and commitments. +func VerifyCells(cells []Cell, commitments []Commitment, proofs []Proof, cellIndices []uint64) error { + if useCKZG.Load() { + return ckzgVerifyCells(cells, commitments, proofs, cellIndices) + } + return gokzgVerifyCells(cells, commitments, proofs, cellIndices) +} + +// ComputeCells computes the cells from the given blobs. +func ComputeCells(blobs []Blob) ([]Cell, error) { + if useCKZG.Load() { + return ckzgComputeCells(blobs) + } + return gokzgComputeCells(blobs) +} + +// RecoverBlobs recovers blobs from the given cells and cell indices. +func RecoverBlobs(cells []Cell, cellIndices []uint64) ([]Blob, error) { + if useCKZG.Load() { + return ckzgRecoverBlobs(cells, cellIndices) + } + return gokzgRecoverBlobs(cells, cellIndices) +} diff --git a/crypto/kzg4844/kzg4844_ckzg_cgo.go b/crypto/kzg4844/kzg4844_ckzg_cgo.go index 93d5f4ff94..ca8a666608 100644 --- a/crypto/kzg4844/kzg4844_ckzg_cgo.go +++ b/crypto/kzg4844/kzg4844_ckzg_cgo.go @@ -190,3 +190,90 @@ func ckzgVerifyCellProofBatch(blobs []Blob, commitments []Commitment, cellProofs } return nil } + +func ckzgVerifyCells(cells []Cell, commitments []Commitment, cellProofs []Proof, cellIndices []uint64) error { + ckzgIniter.Do(ckzgInit) + var ( + proofs = make([]ckzg4844.Bytes48, len(cellProofs)) + commits = make([]ckzg4844.Bytes48, 0, len(cellProofs)) + indices = make([]uint64, 0, len(cellProofs)) + kzgcells = make([]ckzg4844.Cell, 0, len(cellProofs)) + ) + for i := range cellProofs { + proofs[i] = (ckzg4844.Bytes48)(cellProofs[i]) + kzgcells = append(kzgcells, (ckzg4844.Cell)(cells[i])) + } + if len(cellProofs)%len(commitments) != 0 { + return errors.New("wrong cell proofs and commitments length") + } + cellCounts := len(cellProofs) / len(commitments) + for _, commitment := range commitments { + for j := 0; j < cellCounts; j++ { + commits = append(commits, (ckzg4844.Bytes48)(commitment)) + } + } + blobCounts := len(cellProofs) / len(cellIndices) + for j := 0; j < blobCounts; j++ { + indices = append(indices, cellIndices...) + } + + valid, err := ckzg4844.VerifyCellKZGProofBatch(commits, indices, kzgcells, proofs) + if err != nil { + return err + } + if !valid { + return errors.New("invalid proof") + } + return nil +} + +func ckzgComputeCells(blobs []Blob) ([]Cell, error) { + ckzgIniter.Do(ckzgInit) + cells := make([]Cell, 0, ckzg4844.CellsPerExtBlob*len(blobs)) + + for i := range blobs { + cellsI, err := ckzg4844.ComputeCells((*ckzg4844.Blob)(&blobs[i])) + if err != nil { + return []Cell{}, err + } + for _, c := range cellsI { + cells = append(cells, Cell(c)) + } + } + return cells, nil +} + +func ckzgRecoverBlobs(cells []Cell, cellIndices []uint64) ([]Blob, error) { + ckzgIniter.Do(ckzgInit) + + if len(cellIndices) == 0 || len(cells)%len(cellIndices) != 0 { + return []Blob{}, errors.New("cells with wrong length") + } + + blobCount := len(cells) / len(cellIndices) + blobs := make([]Blob, 0, blobCount) + + offset := 0 + for range blobCount { + kzgcells := make([]ckzg4844.Cell, 0, len(cellIndices)) + + for _, cell := range cells[offset : offset+len(cellIndices)] { + kzgcells = append(kzgcells, ckzg4844.Cell(cell)) + } + + extCells, err := ckzg4844.RecoverCells(cellIndices, kzgcells) + if err != nil { + return []Blob{}, err + } + + var blob Blob + for i, cell := range extCells[:64] { + copy(blob[i*len(cell):], cell[:]) + } + blobs = append(blobs, blob) + + offset = offset + len(cellIndices) + } + + return blobs, nil +} diff --git a/crypto/kzg4844/kzg4844_ckzg_nocgo.go b/crypto/kzg4844/kzg4844_ckzg_nocgo.go index 7c552e9a18..e1a3c0af1e 100644 --- a/crypto/kzg4844/kzg4844_ckzg_nocgo.go +++ b/crypto/kzg4844/kzg4844_ckzg_nocgo.go @@ -73,3 +73,15 @@ func ckzgVerifyCellProofBatch(blobs []Blob, commitments []Commitment, proof []Pr func ckzgComputeCellProofs(blob *Blob) ([]Proof, error) { panic("unsupported platform") } + +func ckzgVerifyCells(cells []Cell, commitments []Commitment, cellProofs []Proof, cellIndices []uint64) error { + panic("unsupported platform") +} + +func ckzgComputeCells(blobs []Blob) ([]Cell, error) { + panic("unsupported platform") +} + +func ckzgRecoverBlobs(cells []Cell, cellIndices []uint64) ([]Blob, error) { + panic("unsupported platform") +} diff --git a/crypto/kzg4844/kzg4844_gokzg.go b/crypto/kzg4844/kzg4844_gokzg.go index 03627ebafb..5e998ff81a 100644 --- a/crypto/kzg4844/kzg4844_gokzg.go +++ b/crypto/kzg4844/kzg4844_gokzg.go @@ -18,6 +18,7 @@ package kzg4844 import ( "encoding/json" + "errors" "sync" gokzg4844 "github.com/crate-crypto/go-eth-kzg" @@ -148,3 +149,92 @@ func gokzgVerifyCellProofBatch(blobs []Blob, commitments []Commitment, cellProof } return context.VerifyCellKZGProofBatch(commits, cellIndices, cells[:], proofs) } + +// gokzgVerifyCells verifies that the cell data corresponds to the provided commitment. +func gokzgVerifyCells(cells []Cell, commitments []Commitment, cellProofs []Proof, cellIndices []uint64) error { + gokzgIniter.Do(gokzgInit) + var ( + proofs = make([]gokzg4844.KZGProof, len(cellProofs)) + commits = make([]gokzg4844.KZGCommitment, 0, len(cellProofs)) + indices = make([]uint64, 0, len(cellProofs)) + kzgcells = make([]*gokzg4844.Cell, 0, len(cellProofs)) + ) + // Copy over the cell proofs and cells + for i := range cellProofs { + proofs[i] = gokzg4844.KZGProof(cellProofs[i]) + gc := gokzg4844.Cell(cells[i]) + kzgcells = append(kzgcells, &gc) + } + if len(cellProofs)%len(commitments) != 0 { + return errors.New("wrong cell proofs and commitments length") + } + cellCounts := len(cellProofs) / len(commitments) + // Blow up the commitments to be the same length as the proofs + for _, commitment := range commitments { + for j := 0; j < cellCounts; j++ { + commits = append(commits, gokzg4844.KZGCommitment(commitment)) + } + } + blobCounts := len(cellProofs) / len(cellIndices) + for j := 0; j < blobCounts; j++ { + indices = append(indices, cellIndices...) + } + + return context.VerifyCellKZGProofBatch(commits, indices, kzgcells, proofs) +} + +// gokzgComputeCells computes cells from blobs. +func gokzgComputeCells(blobs []Blob) ([]Cell, error) { + gokzgIniter.Do(gokzgInit) + cells := make([]Cell, 0, gokzg4844.CellsPerExtBlob*len(blobs)) + + for i := range blobs { + cellsI, err := context.ComputeCells((*gokzg4844.Blob)(&blobs[i]), 2) + if err != nil { + return []Cell{}, err + } + for _, c := range cellsI { + if c != nil { + cells = append(cells, Cell(*c)) + } + } + } + return cells, nil +} + +// gokzgRecoverBlobs recovers blobs from cells and cell indices. +func gokzgRecoverBlobs(cells []Cell, cellIndices []uint64) ([]Blob, error) { + gokzgIniter.Do(gokzgInit) + + if len(cellIndices) == 0 || len(cells)%len(cellIndices) != 0 { + return []Blob{}, errors.New("cells with wrong length") + } + + blobCount := len(cells) / len(cellIndices) + blobs := make([]Blob, 0, blobCount) + + offset := 0 + for range blobCount { + kzgcells := make([]*gokzg4844.Cell, 0, len(cellIndices)) + + for _, cell := range cells[offset : offset+len(cellIndices)] { + gc := gokzg4844.Cell(cell) + kzgcells = append(kzgcells, &gc) + } + + extCells, err := context.RecoverCells(cellIndices, kzgcells, 2) + if err != nil { + return []Blob{}, err + } + + var blob Blob + for i, cell := range extCells[:64] { + copy(blob[i*len(cell):], cell[:]) + } + blobs = append(blobs, blob) + + offset = offset + len(cellIndices) + } + + return blobs, nil +} diff --git a/crypto/kzg4844/kzg4844_test.go b/crypto/kzg4844/kzg4844_test.go index 743a277199..779ecd3a75 100644 --- a/crypto/kzg4844/kzg4844_test.go +++ b/crypto/kzg4844/kzg4844_test.go @@ -253,3 +253,119 @@ func benchmarkComputeCellProofs(b *testing.B, ckzg bool) { } } } + +func TestCKZGVerifyPartialCells(t *testing.T) { testVerifyPartialCells(t, true) } +func TestGoKZGVerifyPartialCells(t *testing.T) { testVerifyPartialCells(t, false) } + +func testVerifyPartialCells(t *testing.T, ckzg bool) { + if ckzg && !ckzgAvailable { + t.Skip("CKZG unavailable in this test build") + } + defer func(old bool) { useCKZG.Store(old) }(useCKZG.Load()) + useCKZG.Store(ckzg) + + const blobCount = 3 + var blobs []*Blob + var commitments []Commitment + for range blobCount { + blob := randBlob() + commitment, err := BlobToCommitment(blob) + if err != nil { + t.Fatalf("failed to commit blob: %v", err) + } + blobs = append(blobs, blob) + commitments = append(commitments, commitment) + } + + var ( + partialCells []Cell + partialProofs []Proof + commits []Commitment + indices []uint64 + ) + + for bi, blob := range blobs { + proofs, err := ComputeCellProofs(blob) + if err != nil { + t.Fatalf("failed to compute cell proofs: %v", err) + } + cells, err := ComputeCells([]Blob{*blob}) + if err != nil { + t.Fatalf("failed to compute cells: %v", err) + } + commits = append(commits, commitments[bi]) + + // sample 0, 31, 63, 95 cells + step := len(cells) / 4 + + indices = []uint64{0, uint64(step - 1), uint64(2*step - 1), uint64(3*step - 1)} + for _, idx := range indices { + partialCells = append(partialCells, cells[idx]) + partialProofs = append(partialProofs, proofs[idx]) + } + } + if err := VerifyCells(partialCells, commits, partialProofs, indices); err != nil { + t.Fatalf("failed to verify partial cell proofs: %v", err) + } +} + +func TestCKZGRecoverBlob(t *testing.T) { testRecoverBlob(t, true) } +func TestGoKZGRecoverBlob(t *testing.T) { testRecoverBlob(t, false) } + +func testRecoverBlob(t *testing.T, ckzg bool) { + if ckzg && !ckzgAvailable { + t.Skip("CKZG unavailable in this test build") + } + defer func(old bool) { useCKZG.Store(old) }(useCKZG.Load()) + useCKZG.Store(ckzg) + + blobs := []Blob{} + blobs = append(blobs, *randBlob()) + blobs = append(blobs, *randBlob()) + blobs = append(blobs, *randBlob()) + + cells, err := ComputeCells(blobs) + if err != nil { + t.Fatalf("failed to compute cells: %v", err) + } + proofs := make([]Proof, 0) + commitments := make([]Commitment, len(blobs)) + for i, blob := range blobs { + proof, err := ComputeCellProofs(&blob) + if err != nil { + t.Fatalf("failed to compute proof: %v", err) + } + proofs = append(proofs, proof...) + + commitment, err := BlobToCommitment(&blob) + if err != nil { + t.Fatalf("failed to compute commitment: %v", err) + } + commitments[i] = commitment + } + + var ( + partialCells []Cell + indices []uint64 + ) + + for ci := 64; ci < 128; ci++ { + indices = append(indices, uint64(ci)) + } + + for i := 0; i < len(cells); i += 128 { + start := i + 64 + end := i + 128 + partialCells = append(partialCells, cells[start:end]...) + } + + recoverBlobs, err := RecoverBlobs(partialCells, indices) + + if err != nil { + t.Fatalf("failed to recover blob: %v", err) + } + + if err := VerifyCellProofs(recoverBlobs, commitments, proofs); err != nil { + t.Fatalf("failed to verify recovered blob: %v", err) + } +} diff --git a/eth/backend.go b/eth/backend.go index 72228614f0..8eb4d7139e 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -43,6 +43,7 @@ import ( "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/eth/downloader" "github.com/ethereum/go-ethereum/eth/ethconfig" + "github.com/ethereum/go-ethereum/eth/fetcher" "github.com/ethereum/go-ethereum/eth/gasprice" "github.com/ethereum/go-ethereum/eth/protocols/eth" "github.com/ethereum/go-ethereum/eth/protocols/snap" @@ -335,11 +336,13 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) { Database: chainDb, Chain: eth.blockchain, TxPool: eth.txPool, + BlobPool: eth.blobTxPool, Network: networkID, Sync: config.SyncMode, BloomCache: uint64(cacheLimit), EventMux: eth.eventMux, RequiredBlocks: config.RequiredBlocks, + Custody: *types.CustodyBitmapAll, }); err != nil { return nil, err } @@ -423,6 +426,7 @@ func (s *Ethereum) AccountManager() *accounts.Manager { return s.accountManager func (s *Ethereum) BlockChain() *core.BlockChain { return s.blockchain } func (s *Ethereum) TxPool() *txpool.TxPool { return s.txPool } func (s *Ethereum) BlobTxPool() *blobpool.BlobPool { return s.blobTxPool } +func (s *Ethereum) BlobFetcher() *fetcher.BlobFetcher { return s.handler.blobFetcher } func (s *Ethereum) Engine() consensus.Engine { return s.engine } func (s *Ethereum) ChainDb() ethdb.Database { return s.chainDb } func (s *Ethereum) IsListening() bool { return true } // Always listening diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index 8a4aced04b..d71edeac6b 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -1163,6 +1163,11 @@ func (api *ConsensusAPI) getBodiesByRange(start, count hexutil.Uint64) ([]*engin return bodies, nil } +func (api *ConsensusAPI) BlobCustodyUpdatedV1(custodyColumns []uint64) { + bitmap := types.NewCustodyBitmap(custodyColumns) + api.eth.BlobFetcher().UpdateCustody(bitmap) +} + func getBody(block *types.Block) *engine.ExecutionPayloadBody { if block == nil { return nil diff --git a/eth/catalyst/api_test.go b/eth/catalyst/api_test.go index d126c362fe..c37c432db7 100644 --- a/eth/catalyst/api_test.go +++ b/eth/catalyst/api_test.go @@ -1911,7 +1911,12 @@ func newGetBlobEnv(t testing.TB, version byte) (*node.Node, *ConsensusAPI) { tx1 := makeMultiBlobTx(&config, 0, 2, 0, key1, version) // blob[0, 2) tx2 := makeMultiBlobTx(&config, 0, 2, 2, key2, version) // blob[2, 4) tx3 := makeMultiBlobTx(&config, 0, 2, 4, key3, version) // blob[4, 6) - ethServ.TxPool().Add([]*types.Transaction{tx1, tx2, tx3}, true) + errs := ethServ.TxPool().Add([]*types.Transaction{tx1, tx2, tx3}, true) + for i, err := range errs { + if err != nil { + t.Logf("Add tx %d failed: %v", i, err) + } + } api := newConsensusAPIWithoutHeartbeat(ethServ) return n, api @@ -2108,6 +2113,15 @@ func runGetBlobs(t testing.TB, getBlobs getBlobsFn, start, limit int, fillRandom } } if !reflect.DeepEqual(result, expect) { + t.Logf("result len=%d, expect len=%d", len(result), len(expect)) + if len(result) > 0 && result[0] != nil && len(expect) > 0 && expect[0] != nil { + t.Logf("result[0].Blob len=%d, expect[0].Blob len=%d", len(result[0].Blob), len(expect[0].Blob)) + t.Logf("result[0].CellProofs len=%d, expect[0].CellProofs len=%d", len(result[0].CellProofs), len(expect[0].CellProofs)) + t.Logf("result[0].Blob == expect[0].Blob: %v", reflect.DeepEqual(result[0].Blob, expect[0].Blob)) + t.Logf("result[0].CellProofs == expect[0].CellProofs: %v", reflect.DeepEqual(result[0].CellProofs, expect[0].CellProofs)) + } else { + t.Logf("result[0]=%v, expect[0]=%v", result, expect) + } t.Fatalf("Unexpected result for case %s", name) } } diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go new file mode 100644 index 0000000000..6f030231bf --- /dev/null +++ b/eth/fetcher/blob_fetcher.go @@ -0,0 +1,784 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "math/rand" + "slices" + "sort" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/mclock" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto/kzg4844" +) + +// todo remove partial / full + +type random interface { + Intn(n int) int +} + +// BlobFetcher fetches blobs of new type-3 transactions with probability p, +// and for the remaining (1-p) transactions, it performs availability checks. +// For availability checks, it fetches cells from each blob in the transaction +// according to the custody cell indices provided by the consensus client +// connected to this execution client. + +var blobFetchTimeout = 5 * time.Second + +// todo tuning +const ( + availabilityThreshold = 2 + maxPayloadRetrievals = 128 + maxPayloadAnnounces = 4096 + MAX_CELLS_PER_PARTIAL_REQUEST = 8 + blobAvailabilityTimeout = 500 * time.Millisecond +) + +type blobTxAnnounce struct { + origin string // Identifier of the peer that sent the announcement + txs []common.Hash // Hashes of transactions announced + cells types.CustodyBitmap // Custody information of transactions being announced +} + +type cellRequest struct { + txs []common.Hash // Transactions that have been requested for their cells + cells *types.CustodyBitmap // Requested cell indices + time mclock.AbsTime // Timestamp when the request was made +} + +type payloadDelivery struct { + origin string // Peer from which the payloads were delivered + txs []common.Hash // Hashes of transactions that were delivered + cells [][]kzg4844.Cell + cellBitmap *types.CustodyBitmap +} + +type cellWithSeq struct { + seq uint64 + cells *types.CustodyBitmap +} + +type fetchStatus struct { + fetching *types.CustodyBitmap // To avoid fetching cells which had already been fetched / currently being fetched + fetched []uint64 // To sort cells + cells []kzg4844.Cell +} + +// BlobFetcher is responsible for managing type 3 transactions based on peer announcements. +// +// BlobFetcher manages three buffers: +// - Transactions not to be fetched are moved to "waitlist" +// if a payload(blob) seems to be possessed by D(threshold) other peers, request custody cells for that. +// Accept it when the cells are received. Otherwise, it is dropped. +// - Transactions queued to be fetched are moved to "announces" +// if a payload is received, it is added to the blob pool. Otherwise, the transaction is dropped. +// - Transactions to be fetched are moved to "fetching" +// if a payload/cell announcement is received during fetch, the peer is recorded as an alternate source. +type BlobFetcher struct { + notify chan *blobTxAnnounce + cleanup chan *payloadDelivery + drop chan *txDrop + quit chan struct{} + custody *types.CustodyBitmap + + txSeq uint64 // To make transactions fetched in arrival order + + full map[common.Hash]struct{} + partial map[common.Hash]struct{} + + // Buffer 1: Set of blob txs whose blob data is waiting for availability confirmation (not pull decision) + waitlist map[common.Hash]map[string]struct{} // Peer set that announced blob availability + waittime map[common.Hash]mclock.AbsTime // Timestamp when added to waitlist + waitslots map[string]map[common.Hash]struct{} // Waiting announcements grouped by peer (DoS protection) + // waitSlots should also include announcements with partial cells + + // Buffer 2: Transactions queued for fetching (pull decision + not pull decision) + // "announces" is shared with stage 3, for DoS protection + announces map[string]map[common.Hash]*cellWithSeq // Set of announced transactions, grouped by origin peer + + // Buffer 2 + // Stage 3: Transactions whose payloads/cells are currently being fetched (pull decision + not pull decision) + fetches map[common.Hash]*fetchStatus // Hash -> Bitmap, in-flight transaction cells + requests map[string][]*cellRequest // In-flight transaction retrievals + // todo simplify + alternates map[common.Hash]map[string]*types.CustodyBitmap // In-flight transaction alternate origins (in case the peer is dropped) + + // Callbacks + validateCells func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error + addPayload func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error + fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error + dropPeer func(string) + + step chan struct{} // Notification channel when the fetcher loop iterates + clock mclock.Clock // Monotonic clock or simulated clock for tests + realTime func() time.Time // Real system time or simulated time for tests + rand random // Randomizer +} + +func NewBlobFetcher( + validateCells func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error, + addPayload func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error, + fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error, dropPeer func(string), + custody *types.CustodyBitmap, rand random) *BlobFetcher { + return &BlobFetcher{ + notify: make(chan *blobTxAnnounce), + cleanup: make(chan *payloadDelivery), + drop: make(chan *txDrop), + quit: make(chan struct{}), + full: make(map[common.Hash]struct{}), + partial: make(map[common.Hash]struct{}), + waitlist: make(map[common.Hash]map[string]struct{}), + waittime: make(map[common.Hash]mclock.AbsTime), + waitslots: make(map[string]map[common.Hash]struct{}), + announces: make(map[string]map[common.Hash]*cellWithSeq), + fetches: make(map[common.Hash]*fetchStatus), + requests: make(map[string][]*cellRequest), + alternates: make(map[common.Hash]map[string]*types.CustodyBitmap), + validateCells: validateCells, + addPayload: addPayload, + fetchPayloads: fetchPayloads, + dropPeer: dropPeer, + custody: custody, + clock: mclock.System{}, + realTime: time.Now, + rand: rand, + } +} + +// Notify is called when a Type 3 transaction is observed on the network. (TransactionPacket / NewPooledTransactionHashesPacket) +func (f *BlobFetcher) Notify(peer string, txs []common.Hash, cells types.CustodyBitmap) error { + blobAnnounce := &blobTxAnnounce{origin: peer, txs: txs, cells: cells} + select { + case f.notify <- blobAnnounce: + return nil + case <-f.quit: + return errTerminated + } +} + +// Enqueue inserts a batch of received blob payloads into the blob pool. +// This is triggered by ethHandler upon receiving direct request responses. +func (f *BlobFetcher) Enqueue(peer string, hashes []common.Hash, cells [][]kzg4844.Cell, cellBitmap types.CustodyBitmap) error { + blobReplyInMeter.Mark(int64(len(hashes))) + + select { + case f.cleanup <- &payloadDelivery{origin: peer, txs: hashes, cells: cells, cellBitmap: &cellBitmap}: + case <-f.quit: + return errTerminated + } + return nil +} + +func (f *BlobFetcher) Drop(peer string) error { + select { + case f.drop <- &txDrop{peer: peer}: + return nil + case <-f.quit: + return errTerminated + } +} + +func (f *BlobFetcher) UpdateCustody(cells types.CustodyBitmap) { + f.custody = &cells +} + +func (f *BlobFetcher) Start() { + go f.loop() +} + +func (f *BlobFetcher) Stop() { + close(f.quit) +} + +func (f *BlobFetcher) loop() { + var ( + waitTimer = new(mclock.Timer) // Timer for waitlist (availability) + waitTrigger = make(chan struct{}, 1) + timeoutTimer = new(mclock.Timer) // Timer for payload fetch request + timeoutTrigger = make(chan struct{}, 1) + ) + for { + select { + case ann := <-f.notify: + // Drop part of the announcements if too many have accumulated from that peer + // This prevents a peer from dominating the queue with txs without responding to the request + // todo maxPayloadAnnounces -> according to the number of blobs + used := len(f.waitslots[ann.origin]) + len(f.announces[ann.origin]) + if used >= maxPayloadAnnounces { + // Already full + break + } + + want := used + len(ann.txs) + if want >= maxPayloadAnnounces { + // drop part of announcements + ann.txs = ann.txs[:maxPayloadAnnounces-used] + } + + var ( + idleWait = len(f.waittime) == 0 + _, oldPeer = f.announces[ann.origin] + nextSeq = func() uint64 { + seq := f.txSeq + f.txSeq++ + return seq + } + reschedule = make(map[string]struct{}) + ) + for _, hash := range ann.txs { + if oldPeer && f.announces[ann.origin][hash] != nil { + // Ignore already announced information + // We also have to prevent reannouncement by changing cells field. + // Considering cell custody transition is notified in advance of its finalization by consensus client, + // there is no reason to reannounce cells, and it has to be prevented. + continue + } + // Decide full or partial request + if _, ok := f.full[hash]; !ok { + if _, ok := f.partial[hash]; !ok { + // Not decided yet + var randomValue int + if f.rand == nil { + randomValue = rand.Intn(100) + } else { + randomValue = f.rand.Intn(100) + } + if randomValue < 15 { + f.full[hash] = struct{}{} + } else { + f.partial[hash] = struct{}{} + // Register for availability check + f.waitlist[hash] = make(map[string]struct{}) + f.waittime[hash] = f.clock.Now() + } + } + } + if _, ok := f.full[hash]; ok { + // 1) Decided to send full request of the tx + if ann.cells != *types.CustodyBitmapAll { + continue + } + if f.announces[ann.origin] == nil { + f.announces[ann.origin] = make(map[common.Hash]*cellWithSeq) + } + f.announces[ann.origin][hash] = &cellWithSeq{ + cells: types.CustodyBitmapData, + seq: nextSeq(), + } + reschedule[ann.origin] = struct{}{} + continue + } + if _, ok := f.partial[hash]; ok { + // 2) Decided to send partial request of the tx + if f.waitlist[hash] != nil { + if ann.cells != *types.CustodyBitmapAll { + continue + } + // Transaction is at the stage of availability check + // Add the peer to the peer list with full availability (waitlist) + f.waitlist[hash][ann.origin] = struct{}{} + if waitslots := f.waitslots[ann.origin]; waitslots != nil { + waitslots[hash] = struct{}{} + } else { + f.waitslots[ann.origin] = map[common.Hash]struct{}{ + hash: {}, + } + } + if len(f.waitlist[hash]) >= availabilityThreshold { + for peer := range f.waitlist[hash] { + if f.announces[peer] == nil { + f.announces[peer] = make(map[common.Hash]*cellWithSeq) + } + f.announces[peer][hash] = &cellWithSeq{ + cells: f.custody, + seq: nextSeq(), + } + delete(f.waitslots[peer], hash) + if len(f.waitslots[peer]) == 0 { + delete(f.waitslots, peer) + } + reschedule[peer] = struct{}{} + } + delete(f.waitlist, hash) + } + continue + } + if ann.cells.Intersection(f.custody).OneCount() == 0 { + // No custody overlapping + continue + } + + if f.announces[ann.origin] == nil { + f.announces[ann.origin] = make(map[common.Hash]*cellWithSeq) + } + f.announces[ann.origin][hash] = &cellWithSeq{ + cells: ann.cells.Intersection(f.custody), + seq: nextSeq(), + } + reschedule[ann.origin] = struct{}{} + } + } + + // If a new item was added to the waitlist, schedule its timeout + if idleWait && len(f.waittime) > 0 { + f.rescheduleWait(waitTimer, waitTrigger) + } + + // If this is a new peer and that peer sent transaction with payload flag, + // schedule transaction fetches from it + //todo + if !oldPeer && len(f.announces[ann.origin]) > 0 { + f.scheduleFetches(timeoutTimer, timeoutTrigger, reschedule) + } + + case <-waitTrigger: + // At least one transaction's waiting time ran out, pop all expired ones + // and update the blobpool according to availability + // Availability failure case + for hash, instance := range f.waittime { + if time.Duration(f.clock.Now()-instance)+txGatherSlack > blobAvailabilityTimeout { + // Check if enough peers have announced availability + for peer := range f.waitlist[hash] { + delete(f.waitslots[peer], hash) + if len(f.waitslots[peer]) == 0 { + delete(f.waitslots, peer) + } + } + delete(f.waittime, hash) + delete(f.waitlist, hash) + } + } + + // If transactions are still waiting for availability, reschedule the wait timer + if len(f.waittime) > 0 { + f.rescheduleWait(waitTimer, waitTrigger) + } + + case <-timeoutTrigger: + // Clean up any expired retrievals and avoid re-requesting them from the + // same peer (either overloaded or malicious, useless in both cases). + // Update blobpool according to availability result. + for peer, requests := range f.requests { + newRequests := make([]*cellRequest, 0) + for _, req := range requests { + if time.Duration(f.clock.Now()-req.time)+txGatherSlack > blobFetchTimeout { + // Reschedule all timeout cells to alternate peers + for _, hash := range req.txs { + // Do not request the same tx from this peer + delete(f.announces[peer], hash) + delete(f.alternates[hash], peer) + // Allow other candidates to be requested these cells + f.fetches[hash].fetching = f.fetches[hash].fetching.Difference(req.cells) + + // Drop cells if there is no alternate source to fetch cells from + if len(f.alternates[hash]) == 0 { + delete(f.alternates, hash) + delete(f.fetches, hash) + } + } + if len(f.announces[peer]) == 0 { + delete(f.announces, peer) + } + } else { + newRequests = append(newRequests, req) + } + } + f.requests[peer] = newRequests + if len(f.requests[peer]) == 0 { + delete(f.requests, peer) + } + } + + // Schedule a new transaction retrieval + f.scheduleFetches(timeoutTimer, timeoutTrigger, nil) + + // Trigger timeout for new schedule + f.rescheduleTimeout(timeoutTimer, timeoutTrigger) + case delivery := <-f.cleanup: + // Remove from announce + addedHashes := make([]common.Hash, 0) + addedCells := make([][]kzg4844.Cell, 0) + + var requestId int + request := new(cellRequest) + for _, hash := range delivery.txs { + // Find the request + for i, req := range f.requests[delivery.origin] { + if slices.Contains(req.txs, hash) && *req.cells == *delivery.cellBitmap { + request = req + requestId = i + break + } + } + if request != nil { + break + } + } + if request == nil { + // peer sent cells not requested. ignore + break + } + + for i, hash := range delivery.txs { + if !slices.Contains(request.txs, hash) { + // Unexpected hash, ignore + continue + } + // Update fetch status + f.fetches[hash].fetched = append(f.fetches[hash].fetched, delivery.cellBitmap.Indices()...) + f.fetches[hash].cells = append(f.fetches[hash].cells, delivery.cells[i]...) + + // Update announces of this peer + delete(f.announces[delivery.origin], hash) + if len(f.announces[delivery.origin]) == 0 { + delete(f.announces, delivery.origin) + } + delete(f.alternates[hash], delivery.origin) + if len(f.alternates[hash]) == 0 { + delete(f.alternates, hash) + } + + // Check whether the all required cells are fetched + completed := false + if _, ok := f.full[hash]; ok && len(f.fetches[hash].fetched) >= kzg4844.DataPerBlob { + completed = true + } else if _, ok := f.partial[hash]; ok { + fetched := make([]uint64, len(f.fetches[hash].fetched)) + copy(fetched, f.fetches[hash].fetched) + slices.Sort(fetched) + + custodyIndices := f.custody.Indices() + + completed = slices.Equal(fetched, custodyIndices) + } + + if completed { + addedHashes = append(addedHashes, hash) + fetchStatus := f.fetches[hash] + sort.Slice(fetchStatus.cells, func(i, j int) bool { + return fetchStatus.fetched[i] < fetchStatus.fetched[j] + }) + addedCells = append(addedCells, fetchStatus.cells) + + // remove announces from other peers + for peer, txset := range f.announces { + delete(txset, hash) + if len(txset) == 0 { + delete(f.announces, peer) + } + } + delete(f.alternates, hash) + delete(f.fetches, hash) + } + } + // Update mempool status for arrived hashes + if len(addedHashes) > 0 { + f.addPayload(addedHashes, addedCells, delivery.cellBitmap) + } + + // Remove the request + f.requests[delivery.origin][requestId] = f.requests[delivery.origin][len(f.requests[delivery.origin])-1] + f.requests[delivery.origin] = f.requests[delivery.origin][:len(f.requests[delivery.origin])-1] + if len(f.requests[delivery.origin]) == 0 { + delete(f.requests, delivery.origin) + } + + // Reschedule missing transactions in the request + // Anything not delivered should be re-scheduled (with or without + // this peer, depending on the response cutoff) + delivered := make(map[common.Hash]struct{}) + for _, hash := range delivery.txs { + delivered[hash] = struct{}{} + } + cutoff := len(request.txs) + for i, hash := range request.txs { + if _, ok := delivered[hash]; ok { + cutoff = i + continue + } + } + // Reschedule missing hashes from alternates, not-fulfilled from alt+self + for i, hash := range request.txs { + if _, ok := delivered[hash]; !ok { + // Not delivered + if i < cutoff { + // Remove origin from candidate sources for partial responses + delete(f.alternates[hash], delivery.origin) + delete(f.announces[delivery.origin], hash) + if len(f.announces[delivery.origin]) == 0 { + delete(f.announces, delivery.origin) + } + } + // Mark cells deliverable by other peers + if f.fetches[hash] != nil { + f.fetches[hash].fetching = f.fetches[hash].fetching.Difference(delivery.cellBitmap) + } + } + } + // Something was delivered, try to reschedule requests + f.scheduleFetches(timeoutTimer, timeoutTrigger, nil) // Partial delivery may enable others to deliver too + case drop := <-f.drop: + // A peer was dropped, remove all traces of it + if _, ok := f.waitslots[drop.peer]; ok { + for hash := range f.waitslots[drop.peer] { + delete(f.waitlist[hash], drop.peer) + if len(f.waitlist[hash]) == 0 { + delete(f.waitlist, hash) + delete(f.waittime, hash) + } + } + delete(f.waitslots, drop.peer) + if len(f.waitlist) > 0 { + f.rescheduleWait(waitTimer, waitTrigger) + } + } + // Clean up general announcement tracking + if _, ok := f.announces[drop.peer]; ok { + for hash := range f.announces[drop.peer] { + delete(f.alternates[hash], drop.peer) + if len(f.alternates[hash]) == 0 { + delete(f.alternates, hash) + } + } + delete(f.announces, drop.peer) + } + delete(f.announces, drop.peer) + + // Clean up any active requests + if request, ok := f.requests[drop.peer]; ok && len(request) != 0 { + for _, req := range request { + for _, hash := range req.txs { + // Undelivered hash, reschedule if there's an alternative origin available + f.fetches[hash].fetching = f.fetches[hash].fetching.Difference(req.cells) + delete(f.alternates[hash], drop.peer) + if len(f.alternates[hash]) == 0 { + delete(f.alternates, hash) + delete(f.fetches, hash) + } + } + } + delete(f.requests, drop.peer) + // If a request was cancelled, check if anything needs to be rescheduled + f.scheduleFetches(timeoutTimer, timeoutTrigger, nil) + f.rescheduleTimeout(timeoutTimer, timeoutTrigger) + } + + case <-f.quit: + return + } + // Loop did something, ping the step notifier if needed (tests) + if f.step != nil { + f.step <- struct{}{} + } + } +} + +func (f *BlobFetcher) rescheduleWait(timer *mclock.Timer, trigger chan struct{}) { + if *timer != nil { + (*timer).Stop() + } + now := f.clock.Now() + + earliest := now + for _, instance := range f.waittime { + if earliest > instance { + earliest = instance + if txArriveTimeout-time.Duration(now-earliest) < txGatherSlack { + break + } + } + } + *timer = f.clock.AfterFunc(txArriveTimeout-time.Duration(now-earliest), func() { + trigger <- struct{}{} + }) +} + +// Exactly same as the one in TxFetcher +func (f *BlobFetcher) rescheduleTimeout(timer *mclock.Timer, trigger chan struct{}) { + if *timer != nil { + (*timer).Stop() + } + now := f.clock.Now() + + earliest := now + for _, requests := range f.requests { + for _, req := range requests { + // If this request already timed out, skip it altogether + if req.txs == nil { + continue + } + if earliest > req.time { + earliest = req.time + if blobFetchTimeout-time.Duration(now-earliest) < txGatherSlack { + break + } + } + } + } + *timer = f.clock.AfterFunc(blobFetchTimeout-time.Duration(now-earliest), func() { + trigger <- struct{}{} + }) +} +func (f *BlobFetcher) scheduleFetches(timer *mclock.Timer, timeout chan struct{}, whitelist map[string]struct{}) { + // Gather the set of peers we want to retrieve from (default to all) + actives := whitelist + if actives == nil { + actives = make(map[string]struct{}) + for peer := range f.announces { + actives[peer] = struct{}{} + } + } + if len(actives) == 0 { + return + } + // For each active peer, try to schedule some payload fetches + idle := len(f.requests) == 0 + + f.forEachPeer(actives, func(peer string) { + if len(f.announces[peer]) == 0 || len(f.requests[peer]) != 0 { + return // continue + } + var ( + hashes = make([]common.Hash, 0, maxTxRetrievals) + custodies = make([]*types.CustodyBitmap, 0, maxTxRetrievals) + ) + f.forEachAnnounce(f.announces[peer], func(hash common.Hash, cells *types.CustodyBitmap) bool { + var difference *types.CustodyBitmap + + if f.fetches[hash] == nil { + // tx is not being fetched + difference = cells + } else { + difference = cells.Difference(f.fetches[hash].fetching) + } + + // Mark fetching for differences + if difference.OneCount() != 0 { + if f.fetches[hash] == nil { + f.fetches[hash] = &fetchStatus{ + fetching: difference, + fetched: make([]uint64, 0), + cells: make([]kzg4844.Cell, 0), + } + } else { + f.fetches[hash].fetching = f.fetches[hash].fetching.Union(difference) + } + // Accumulate the hash and stop if the limit was reached + hashes = append(hashes, hash) + custodies = append(custodies, difference) + } + + // Mark alternatives + if f.alternates[hash] == nil { + f.alternates[hash] = map[string]*types.CustodyBitmap{ + peer: cells, + } + } else { + f.alternates[hash][peer] = cells + } + + return len(hashes) < maxPayloadRetrievals + }) + // If any hashes were allocated, request them from the peer + if len(hashes) > 0 { + // Group hashes by custody bitmap + requestByCustody := make(map[string]*cellRequest) + + for i, hash := range hashes { + custody := custodies[i] + + key := string(custody[:]) + + if _, ok := requestByCustody[key]; !ok { + requestByCustody[key] = &cellRequest{ + txs: []common.Hash{}, + cells: custody, + time: f.clock.Now(), + } + } + requestByCustody[key].txs = append(requestByCustody[key].txs, hash) + } + // construct request + var request []*cellRequest + for _, cr := range requestByCustody { + request = append(request, cr) + } + f.requests[peer] = request + go func(peer string, request []*cellRequest) { + for _, req := range request { + if err := f.fetchPayloads(peer, req.txs, req.cells); err != nil { + f.Drop(peer) + break + } + } + }(peer, request) + } + }) + // If a new request was fired, schedule a timeout timer + if idle && len(f.requests) > 0 { + f.rescheduleTimeout(timer, timeout) + } +} + +// forEachAnnounce loops over the given announcements in arrival order, invoking +// the do function for each until it returns false. We enforce an arrival +// ordering to minimize the chances of transaction nonce-gaps, which result in +// transactions being rejected by the txpool. +func (f *BlobFetcher) forEachAnnounce(announces map[common.Hash]*cellWithSeq, do func(hash common.Hash, cells *types.CustodyBitmap) bool) { + type announcement struct { + hash common.Hash + cells *types.CustodyBitmap + seq uint64 + } + // Process announcements by their arrival order + list := make([]announcement, 0, len(announces)) + for hash, entry := range announces { + list = append(list, announcement{hash: hash, cells: entry.cells, seq: entry.seq}) + } + sort.Slice(list, func(i, j int) bool { + return list[i].seq < list[j].seq + }) + for i := range list { + if !do(list[i].hash, list[i].cells) { + return + } + } +} + +// forEachPeer does a range loop over a map of peers in production, but during +// testing it does a deterministic sorted random to allow reproducing issues. +func (f *BlobFetcher) forEachPeer(peers map[string]struct{}, do func(peer string)) { + // If we're running production(step == nil), use whatever Go's map gives us + if f.step == nil { + for peer := range peers { + do(peer) + } + return + } + // We're running the test suite, make iteration deterministic (sorted by peer id) + list := make([]string, 0, len(peers)) + for peer := range peers { + list = append(list, peer) + } + sort.Strings(list) + for _, peer := range list { + do(peer) + } +} diff --git a/eth/fetcher/blob_fetcher_test.go b/eth/fetcher/blob_fetcher_test.go new file mode 100644 index 0000000000..02b42eda3b --- /dev/null +++ b/eth/fetcher/blob_fetcher_test.go @@ -0,0 +1,999 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package fetcher + +import ( + "slices" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/mclock" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto/kzg4844" +) + +// makeTestBlobSidecar is a helper method to create random blob sidecar +// with certain number of blobs. +func makeTestCellSidecar(blobCount int) *types.BlobTxCellSidecar { + var ( + blobs []kzg4844.Blob + commitments []kzg4844.Commitment + proofs []kzg4844.Proof + ) + + for i := 0; i < blobCount; i++ { + blob := &kzg4844.Blob{} + blob[0] = byte(i) + blobs = append(blobs, *blob) + + commit, _ := kzg4844.BlobToCommitment(blob) + commitments = append(commitments, commit) + + cellProofs, _ := kzg4844.ComputeCellProofs(blob) + proofs = append(proofs, cellProofs...) + } + + sidecar, _ := types.NewBlobTxSidecar(types.BlobSidecarVersion1, blobs, commitments, proofs).ToBlobTxCellSidecar() + + return sidecar +} + +func selectCells(cells []kzg4844.Cell, custody *types.CustodyBitmap) []kzg4844.Cell { + custodyIndices := custody.Indices() + result := make([]kzg4844.Cell, 0) + + for _, idx := range custodyIndices { + result = append(result, cells[idx]) + } + + return result +} + +const ( + testBlobAvailabilityTimeout = 500 * time.Millisecond + testBlobFetchTimeout = 5 * time.Second +) + +var ( + testBlobTxHashes = []common.Hash{ + {0x01}, {0x02}, {0x03}, {0x04}, {0x05}, {0x06}, {0x07}, {0x08}, + } + + testBlobSidecars = []*types.BlobTxCellSidecar{ + makeTestCellSidecar(1), + makeTestCellSidecar(2), + makeTestCellSidecar(3), + makeTestCellSidecar(4), + } + + custody = types.NewCustodyBitmap([]uint64{0, 1, 2, 3, 4, 5, 6, 7}) + + fullCustody = *types.CustodyBitmapAll + halfCustody = *types.CustodyBitmapData + frontCustody = types.NewCustodyBitmap([]uint64{0, 1, 2, 3, 8, 9, 10, 11}) + backCustody = types.NewCustodyBitmap([]uint64{4, 5, 6, 7, 8, 9, 10, 11}) + differentCustody = types.NewCustodyBitmap([]uint64{8, 9, 10, 11, 12, 13, 14, 15}) +) + +type doBlobNotify struct { + peer string + hashes []common.Hash + custody types.CustodyBitmap +} + +type doBlobEnqueue struct { + peer string + hashes []common.Hash + cells [][]kzg4844.Cell + custody types.CustodyBitmap +} + +type blobDoFunc func(*BlobFetcher) + +type isWaitingAvailability map[common.Hash]map[string]struct{} + +type isDecidedFull map[common.Hash]struct{} +type isDecidedPartial map[common.Hash]struct{} + +type blobAnnounce struct { + hash common.Hash + custody types.CustodyBitmap +} + +type isBlobScheduled struct { + announces map[string][]blobAnnounce // announces에 있는 것들 (peer -> hash+custody) + fetching map[string][]blobAnnounce // requests에 있는 것들 (peer -> hash+custody) +} + +type isCompleted []common.Hash +type isDropped []string + +type isFetching struct { + hashes map[common.Hash]fetchInfo +} + +type fetchInfo struct { + fetching *types.CustodyBitmap + fetched []uint64 +} + +type blobFetcherTest struct { + init func() *BlobFetcher + steps []interface{} +} + +type mockRand struct { + value int +} + +func (r *mockRand) Intn(n int) int { + return r.value +} + +// TestBlobFetcherFullSchedule tests scheduling full payload decision +// Blob should be fetched immediately when its availability is announced +// by idle peer, if the client decided to pull the full payload +// Additional announcements should be recorded as alternates during the fetch +func TestBlobFetcherFullFetch(t *testing.T) { + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 5}, // to force full requests (5 < 15) + ) + }, + steps: []interface{}{ + // A announced full custody blob (should make full decision & start fetching) + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isDecidedFull{testBlobTxHashes[0]: struct{}{}}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &halfCustody, + fetched: []uint64{}, + }, + }, + }, + + // Same hash announced by another peer(B) -> should be added to alternatives + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + + // Announce partial custody by C -> should be ignored + doBlobNotify{peer: "C", hashes: []common.Hash{testBlobTxHashes[1]}, custody: custody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + + // Additional hashes announced by A -> should not be fetched + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[1]}, custody: fullCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}, {hash: testBlobTxHashes[1], custody: halfCustody}}, + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + + // Announce of multiple transactions + doBlobNotify{peer: "D", hashes: []common.Hash{testBlobTxHashes[2], testBlobTxHashes[3]}, custody: fullCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}, {hash: testBlobTxHashes[1], custody: halfCustody}}, + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + "D": {{hash: testBlobTxHashes[2], custody: halfCustody}, {hash: testBlobTxHashes[3], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + "D": {{hash: testBlobTxHashes[2], custody: halfCustody}, {hash: testBlobTxHashes[3], custody: halfCustody}}, + }, + }, + }, + }) +} + +// TestBlobFetcherPartialFetching tests partial request decision and availability check flow +func TestBlobFetcherPartialFetch(t *testing.T) { + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 20}, // Force partial requests (20 >= 15) + ) + }, + steps: []interface{}{ + // First full announce for tx 0, 1 -> should make partial decision and go to waitlist + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0], testBlobTxHashes[1]}, custody: fullCustody}, + isDecidedPartial{testBlobTxHashes[0]: struct{}{}, testBlobTxHashes[1]: struct{}{}}, + isWaitingAvailability{testBlobTxHashes[0]: map[string]struct{}{"A": {}}, testBlobTxHashes[1]: map[string]struct{}{"A": {}}}, + isBlobScheduled{announces: nil, fetching: nil}, + + // Partial announce for tx 0 (waitlist) -> should be dropped + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: custody}, + isWaitingAvailability{testBlobTxHashes[0]: map[string]struct{}{"A": {}}, testBlobTxHashes[1]: map[string]struct{}{"A": {}}}, + isBlobScheduled{announces: nil, fetching: nil}, + + // Second full announce for tx 0 -> should make tx 0 available & fetched + doBlobNotify{peer: "C", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isWaitingAvailability{testBlobTxHashes[1]: map[string]struct{}{"A": {}}}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + "C": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &custody, + fetched: []uint64{}, + }, + }, + }, + + // Partial announce for tx 0, overlapped custody -> overlapping part should be accepted + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: frontCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + "B": {{hash: testBlobTxHashes[0], custody: *frontCustody.Intersection(&custody)}}, + "C": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + }, + + // Partial announce for tx 0, with additional custody -> don't update + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: custody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + "B": {{hash: testBlobTxHashes[0], custody: *frontCustody.Intersection(&custody)}}, + "C": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + }, + + // Partial announce for tx 0, without any overlapped custody -> should be dropped + doBlobNotify{peer: "D", hashes: []common.Hash{testBlobTxHashes[0]}, custody: differentCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + "B": {{hash: testBlobTxHashes[0], custody: *frontCustody.Intersection(&custody)}}, + "C": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + }, + }, + }) +} + +// todo wait timeout +// todo drop + +// TestBlobFetcherFullDelivery tests cell delivery and fetch completion logic (full fetch) +func TestBlobFetcherFullDelivery(t *testing.T) { + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 5}, // Force full requests for simplicity + ) + }, + steps: []interface{}{ + // Full announce by two peers (A, B) -> schedule fetch + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &halfCustody, + fetched: []uint64{}, + }, + }, + }, + + // All alternates should be clean up on delivery + doBlobEnqueue{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, cells: [][]kzg4844.Cell{selectCells(testBlobSidecars[0].Cells, &halfCustody)}, custody: halfCustody}, + isBlobScheduled{announces: nil, fetching: nil}, + isFetching{hashes: nil}, // fetches should be empty after completion + isCompleted{testBlobTxHashes[0]}, + }, + }) +} + +// TestBlobFetcherPartialDelivery tests cell delivery and fetch completion logic (partial fetch) +func TestBlobFetcherPartialDelivery(t *testing.T) { + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 20}, + ) + }, + steps: []interface{}{ + // Full announce by two peers (A, B) -> schedule fetch + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isWaitingAvailability(nil), + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + "B": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &custody, + fetched: []uint64{}, + }, + }, + }, + + // Partial announce by C, D -> alternates + doBlobNotify{peer: "C", hashes: []common.Hash{testBlobTxHashes[0]}, custody: frontCustody}, + doBlobNotify{peer: "D", hashes: []common.Hash{testBlobTxHashes[0]}, custody: backCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + "B": {{hash: testBlobTxHashes[0], custody: custody}}, + "C": {{hash: testBlobTxHashes[0], custody: *frontCustody.Intersection(&custody)}}, + "D": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + }, + + // Drop A, B -> schedule fetch from C, D + doDrop("A"), + doDrop("B"), + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "C": {{hash: testBlobTxHashes[0], custody: *frontCustody.Intersection(&custody)}}, + "D": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + }, + fetching: map[string][]blobAnnounce{ + "C": {{hash: testBlobTxHashes[0], custody: *frontCustody.Intersection(&custody)}}, + "D": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + }, + }, + + // Delivery from C -> wait for D + doBlobEnqueue{ + peer: "C", + hashes: []common.Hash{testBlobTxHashes[0]}, + cells: [][]kzg4844.Cell{selectCells(testBlobSidecars[0].Cells, frontCustody.Intersection(&custody))}, + custody: *frontCustody.Intersection(&custody), + }, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "D": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + }, + fetching: map[string][]blobAnnounce{ + "D": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &custody, + fetched: frontCustody.Intersection(&custody).Indices(), + }, + }, + }, + + // Announce already delivered cells + fetching cells -> leave fetching cells only + doBlobNotify{peer: "E", hashes: []common.Hash{testBlobTxHashes[0]}, custody: custody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "D": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + "E": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + fetching: map[string][]blobAnnounce{ + "D": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + }, + }, + + // Not delivered -> reschedule to E + doWait{time: blobFetchTimeout, step: true}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "E": {{hash: testBlobTxHashes[0], custody: custody}}, + }, + fetching: map[string][]blobAnnounce{ + "E": {{hash: testBlobTxHashes[0], custody: *backCustody.Intersection(&custody)}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &custody, + fetched: frontCustody.Intersection(&custody).Indices(), + }, + }, + }, + // Delivery from E -> complete + doWait{time: blobFetchTimeout / 2, step: true}, + doBlobEnqueue{ + peer: "E", + hashes: []common.Hash{testBlobTxHashes[0]}, + cells: [][]kzg4844.Cell{selectCells(testBlobSidecars[0].Cells, backCustody.Intersection(&custody))}, + custody: *backCustody.Intersection(&custody), + }, + isCompleted{testBlobTxHashes[0]}, + }, + }) +} + +// TestBlobFetcherAvailabilityTimeout tests availability timeout for partial requests +func TestBlobFetcherAvailabilityTimeout(t *testing.T) { + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 20}, + ) + }, + steps: []interface{}{ + // First full announce for tx 0 -> should make partial decision and go to waitlist + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isDecidedPartial{testBlobTxHashes[0]: struct{}{}}, + isWaitingAvailability{testBlobTxHashes[0]: map[string]struct{}{"A": {}}}, + isBlobScheduled{announces: nil, fetching: nil}, + + // Run clock for timeout + doWait{time: testBlobAvailabilityTimeout, step: true}, + + // After timeout, waitlist should be empty + isWaitingAvailability{}, + isBlobScheduled{announces: nil, fetching: nil}, + }, + }) +} + +// TestBlobFetcherPeerDrop tests peer drop scenarios +func TestBlobFetcherPeerDrop(t *testing.T) { + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 5}, + ) + }, + steps: []interface{}{ + // Full announce by peer A -> should schedule fetch + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isDecidedFull{testBlobTxHashes[0]: struct{}{}}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &halfCustody, + fetched: []uint64{}, + }, + }, + }, + + // Another peer B announces same hash -> should be added to alternates + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + + // Drop peer A -> should reschedule fetch to peer B + doDrop("A"), + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &halfCustody, + fetched: []uint64{}, + }, + }, + }, + + // Drop peer B -> should drop the transaction, remove all traces + doDrop("B"), + isBlobScheduled{announces: nil, fetching: nil}, + isFetching{hashes: nil}, + }, + }) +} + +// TestBlobFetcherFetchTimeout tests fetch timeout and rescheduling, full request case +func TestBlobFetcherFetchTimeout(t *testing.T) { + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 5}, + ) + }, + steps: []interface{}{ + // Full announce by peer A -> schedule fetch + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isDecidedFull{testBlobTxHashes[0]: struct{}{}}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &halfCustody, + fetched: []uint64{}, + }, + }, + }, + + // Another peer announces same hash -> should be added to alternates + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + + // Wait for fetch timeout -> should reschedule to peer B + doWait{time: testBlobFetchTimeout, step: true}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, + isFetching{ + hashes: map[common.Hash]fetchInfo{ + testBlobTxHashes[0]: { + fetching: &halfCustody, + fetched: []uint64{}, + }, + }, + }, + + // Wait for timeout -> should drop transaction + doWait{time: testBlobFetchTimeout, step: true}, + isBlobScheduled{announces: nil, fetching: nil}, + isFetching{hashes: nil}, + }, + }) +} + +// testBlobFetcher is the generic test runner for blob fetcher tests +func testBlobFetcher(t *testing.T, tt blobFetcherTest) { + clock := new(mclock.Simulated) + wait := make(chan struct{}) + + // Create a fetcher and boot it up + fetcher := tt.init() + fetcher.clock = clock + fetcher.step = wait + + fetcher.Start() + defer fetcher.Stop() + + defer func() { + for { + select { + case <-wait: + default: + return + } + } + }() + + // Iterate through all the test steps and execute them + for i, step := range tt.steps { + // Clear the channel if anything is left over + for len(wait) > 0 { + <-wait + } + // Process the next step of the test + switch step := step.(type) { + case doBlobNotify: + if err := fetcher.Notify(step.peer, step.hashes, step.custody); err != nil { + t.Errorf("step %d: failed to notify fetcher: %v", i, err) + return + } + <-wait + + case doBlobEnqueue: + if err := fetcher.Enqueue(step.peer, step.hashes, step.cells, step.custody); err != nil { + t.Errorf("step %d: failed to enqueue blobs: %v", i, err) + return + } + <-wait + + case blobDoFunc: + step(fetcher) + + case isWaitingAvailability: + // Check expected hashes and peers are present + for hash, peers := range step { + if waitPeers, ok := fetcher.waitlist[hash]; !ok { + t.Errorf("step %d: hash %x not in waitlist", i, hash) + return + } else { + // Check expected peers are present + for peer := range peers { + if _, ok := waitPeers[peer]; !ok { + t.Errorf("step %d: peer %s not waiting for hash %x", i, peer, hash) + return + } + } + // Check no unexpected peers are present + for peer := range waitPeers { + if _, ok := peers[peer]; !ok { + t.Errorf("step %d: unexpected peer %s waiting for hash %x", i, peer, hash) + return + } + } + } + } + // Check no unexpected hashes in waitlist + for hash := range fetcher.waitlist { + if _, ok := step[hash]; !ok { + t.Errorf("step %d: unexpected hash %x in waitlist", i, hash) + return + } + } + + case isDecidedFull: + for hash := range step { + if _, ok := fetcher.full[hash]; !ok { + t.Errorf("step %d: hash %x not decided for full request", i, hash) + return + } + } + + case isDecidedPartial: + for hash := range step { + if _, ok := fetcher.partial[hash]; !ok { + t.Errorf("step %d: hash %x not decided for partial request", i, hash) + return + } + } + + case isBlobScheduled: + // todo fetches + // Check tracking (announces) - bidirectional verification + for peer, announces := range step.announces { + peerAnnounces := fetcher.announces[peer] + if peerAnnounces == nil { + t.Errorf("step %d: peer %s missing from announces", i, peer) + continue + } + // Check expected announces are present + for _, ann := range announces { + if cellWithSeq, ok := peerAnnounces[ann.hash]; !ok { + t.Errorf("step %d, peer %s: hash %x missing from announces", i, peer, ann.hash) + } else if *cellWithSeq.cells != ann.custody { + t.Errorf("step %d, peer %s, hash %x: custody mismatch in announces", i, peer, ann.hash) + } + } + // Check no unexpected announces are present + for hash := range peerAnnounces { + found := false + for _, ann := range announces { + if ann.hash == hash { + found = true + break + } + } + if !found { + t.Errorf("step %d, peer %s: unexpected hash %x in announces", i, peer, hash) + } + } + } + // Check no unexpected peers in announces + for peer := range fetcher.announces { + if _, ok := step.announces[peer]; !ok { + t.Errorf("step %d: unexpected peer %s in announces", i, peer) + } + } + + // Check fetching (requests) + for peer, requests := range step.fetching { + peerRequests := fetcher.requests[peer] + if peerRequests == nil { + t.Errorf("step %d: peer %s missing from requests", i, peer) + continue + } + // Check expected requests are present + for _, req := range requests { + found := false + for _, cellReq := range peerRequests { + for _, hash := range cellReq.txs { + if hash == req.hash && *cellReq.cells == req.custody { + found = true + break + } + } + if found { + break + } + } + if !found { + t.Errorf("step %d, peer %s: hash %x with custody not found in requests", i, peer, req.hash) + } + } + // (bidirectional) Check no unexpected requests are present + for _, cellReq := range peerRequests { + for _, hash := range cellReq.txs { + found := false + for _, req := range requests { + if req.hash == hash && *cellReq.cells == req.custody { + found = true + break + } + } + if !found { + t.Errorf("step %d, peer %s: unexpected hash %x in requests", i, peer, hash) + } + } + } + } + // Check no unexpected peers in requests + for peer := range fetcher.requests { + if _, ok := step.fetching[peer]; !ok { + t.Errorf("step %d: unexpected peer %s in requests", i, peer) + } + } + + // Check internal consistency: alternates should match announces + // For every hash being fetched, alternates should contain all peers who announced it + for _, announces := range step.fetching { + for _, announce := range announces { + hash := announce.hash + alternates := fetcher.alternates[hash] + if alternates == nil { + t.Errorf("step %d: hash %x missing from alternates", i, hash) + continue + } + + // Check that all peers with this hash in announces are in alternates with matching custody + for peer, peerAnnounces := range fetcher.announces { + if cellWithSeq := peerAnnounces[hash]; cellWithSeq != nil { + if altCustody, ok := alternates[peer]; !ok { + t.Errorf("step %d, hash %x: peer %s missing from alternates", i, hash, peer) + } else if *altCustody != *cellWithSeq.cells { + t.Errorf("step %d, hash %x, peer %s: custody bitmap mismatch in alternates", i, hash, peer) + } + } + } + + // Check that all peers in alternates actually have this hash announced with matching custody + for peer, altCustody := range alternates { + if fetcher.announces[peer] == nil || fetcher.announces[peer][hash] == nil { + t.Errorf("step %d, hash %x: peer %s extra in alternates", i, hash, peer) + } else if cellWithSeq := fetcher.announces[peer][hash]; *cellWithSeq.cells != *altCustody { + t.Errorf("step %d, hash %x, peer %s: custody bitmap mismatch between announces and alternates", i, hash, peer) + } + } + } + } + + case isFetching: + // Check expected hashes are present in fetches + for hash, expected := range step.hashes { + if fetchStatus, ok := fetcher.fetches[hash]; !ok { + t.Errorf("step %d: hash %x missing from fetches", i, hash) + } else { + // Check fetching bitmap + if expected.fetching != nil { + if fetchStatus.fetching == nil { + t.Errorf("step %d, hash %x: fetching bitmap is nil", i, hash) + } else if *fetchStatus.fetching != *expected.fetching { + t.Errorf("step %d, hash %x: fetching bitmap mismatch", i, hash) + } + } + + // Check fetched indices + if expected.fetched != nil { + if len(fetchStatus.fetched) != len(expected.fetched) { + t.Errorf("step %d, hash %x: fetched length mismatch, got %d, want %d", i, hash, len(fetchStatus.fetched), len(expected.fetched)) + } else { + // Sort both slices before comparing + gotFetched := make([]uint64, len(fetchStatus.fetched)) + copy(gotFetched, fetchStatus.fetched) + slices.Sort(gotFetched) + + expectedFetched := make([]uint64, len(expected.fetched)) + copy(expectedFetched, expected.fetched) + slices.Sort(expectedFetched) + + if !slices.Equal(gotFetched, expectedFetched) { + t.Errorf("step %d, hash %x: fetched indices mismatch", i, hash) + } + } + } + } + } + // Check no unexpected hashes in fetches + for hash := range fetcher.fetches { + if _, ok := step.hashes[hash]; !ok { + t.Errorf("step %d: unexpected hash %x in fetches", i, hash) + } + } + + case isCompleted: + for _, hash := range step { + if _, ok := fetcher.fetches[hash]; ok { + t.Errorf("step %d: hash %x still in fetches (should be completed)", i, hash) + return + } + } + + case isDropped: + for _, peer := range step { + if _, ok := fetcher.announces[peer]; ok { + t.Errorf("step %d: peer %s still has announces (should be dropped)", i, peer) + return + } + } + + case doWait: + clock.Run(step.time) + if step.step { + <-wait + } + + case doDrop: + if err := fetcher.Drop(string(step)); err != nil { + t.Errorf("step %d: %v", i, err) + } + <-wait + + default: + t.Errorf("step %d: unknown step type %T", i, step) + return + } + } +} diff --git a/eth/fetcher/metrics.go b/eth/fetcher/metrics.go index 3c0d6a8fd8..306690c64b 100644 --- a/eth/fetcher/metrics.go +++ b/eth/fetcher/metrics.go @@ -57,4 +57,25 @@ var ( // to become "unfrozen", either by eventually replying to the request // or by being dropped, measuring from the moment the request was sent. txFetcherSlowWait = metrics.NewRegisteredHistogram("eth/fetcher/transaction/slow/wait", nil, metrics.NewExpDecaySample(1028, 0.015)) + + blobAnnounceInMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/in", nil) + blobAnnounceDOSMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/dos", nil) + + blobRequestOutMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/out", nil) + blobRequestFailMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/fail", nil) + blobRequestDoneMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/done", nil) + blobRequestTimeoutMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/timeout", nil) + + blobReplyInMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/replies/in", nil) + blobReplyRejectMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/replies/reject", nil) + + blobFetcherWaitingPeers = metrics.NewRegisteredGauge("eth/fetcher/blob/waiting/peers", nil) + blobFetcherWaitingHashes = metrics.NewRegisteredGauge("eth/fetcher/blob/waiting/hashes", nil) + blobFetcherQueueingPeers = metrics.NewRegisteredGauge("eth/fetcher/blob/queueing/peers", nil) + blobFetcherQueueingHashes = metrics.NewRegisteredGauge("eth/fetcher/blob/queueing/hashes", nil) + blobFetcherFetchingPeers = metrics.NewRegisteredGauge("eth/fetcher/blob/fetching/peers", nil) + blobFetcherFetchingHashes = metrics.NewRegisteredGauge("eth/fetcher/blob/fetching/hashes", nil) + + blobFetcherWaitTime = metrics.NewRegisteredHistogram("eth/fetcher/blob/wait/time", nil, metrics.NewExpDecaySample(1028, 0.015)) + blobFetcherFetchTime = metrics.NewRegisteredHistogram("eth/fetcher/blob/fetch/time", nil, metrics.NewExpDecaySample(1028, 0.015)) ) diff --git a/eth/fetcher/tx_fetcher.go b/eth/fetcher/tx_fetcher.go index 5817dfbcf5..271c9ddec2 100644 --- a/eth/fetcher/tx_fetcher.go +++ b/eth/fetcher/tx_fetcher.go @@ -232,7 +232,7 @@ func NewTxFetcherForTests( // Notify announces the fetcher of the potential availability of a new batch of // transactions in the network. -func (f *TxFetcher) Notify(peer string, types []byte, sizes []uint32, hashes []common.Hash) error { +func (f *TxFetcher) Notify(peer string, kinds []byte, sizes []uint32, hashes []common.Hash) ([]common.Hash, error) { // Keep track of all the announced transactions txAnnounceInMeter.Mark(int64(len(hashes))) @@ -245,12 +245,14 @@ func (f *TxFetcher) Notify(peer string, types []byte, sizes []uint32, hashes []c unknownHashes = make([]common.Hash, 0, len(hashes)) unknownMetas = make([]txMetadata, 0, len(hashes)) + blobFetchHashes = make([]common.Hash, 0, len(hashes)) + duplicate int64 onchain int64 underpriced int64 ) for i, hash := range hashes { - err := f.validateMeta(hash, types[i]) + err := f.validateMeta(hash, kinds[i]) if errors.Is(err, txpool.ErrAlreadyKnown) { duplicate++ continue @@ -271,11 +273,14 @@ func (f *TxFetcher) Notify(peer string, types []byte, sizes []uint32, hashes []c } unknownHashes = append(unknownHashes, hash) + if kinds[i] == types.BlobTxType { + blobFetchHashes = append(blobFetchHashes, hash) + } // Transaction metadata has been available since eth68, and all // legacy eth protocols (prior to eth68) have been deprecated. // Therefore, metadata is always expected in the announcement. - unknownMetas = append(unknownMetas, txMetadata{kind: types[i], size: sizes[i]}) + unknownMetas = append(unknownMetas, txMetadata{kind: kinds[i], size: sizes[i]}) } txAnnounceKnownMeter.Mark(duplicate) txAnnounceUnderpricedMeter.Mark(underpriced) @@ -283,14 +288,14 @@ func (f *TxFetcher) Notify(peer string, types []byte, sizes []uint32, hashes []c // If anything's left to announce, push it into the internal loop if len(unknownHashes) == 0 { - return nil + return blobFetchHashes, nil } announce := &txAnnounce{origin: peer, hashes: unknownHashes, metas: unknownMetas} select { case f.notify <- announce: - return nil + return blobFetchHashes, nil case <-f.quit: - return errTerminated + return nil, errTerminated } } diff --git a/eth/fetcher/tx_fetcher_test.go b/eth/fetcher/tx_fetcher_test.go index 6c2719631e..de8413142a 100644 --- a/eth/fetcher/tx_fetcher_test.go +++ b/eth/fetcher/tx_fetcher_test.go @@ -1888,7 +1888,7 @@ func testTransactionFetcher(t *testing.T, tt txFetcherTest) { // Process the original or expanded steps switch step := step.(type) { case doTxNotify: - if err := fetcher.Notify(step.peer, step.types, step.sizes, step.hashes); err != nil { + if _, err := fetcher.Notify(step.peer, step.types, step.sizes, step.hashes); err != nil { t.Errorf("step %d: %v", i, err) } <-wait // Fetcher needs to process this, wait until it's done diff --git a/eth/handler.go b/eth/handler.go index 27b5e60697..4dbb764d92 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -33,6 +33,7 @@ import ( "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/txpool" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/eth/downloader" "github.com/ethereum/go-ethereum/eth/ethconfig" "github.com/ethereum/go-ethereum/eth/fetcher" @@ -75,7 +76,7 @@ type txPool interface { // GetRLP retrieves the RLP-encoded transaction from local txpool // with given tx hash. - GetRLP(hash common.Hash) []byte + GetRLP(hash common.Hash, includeBlob bool) []byte // GetMetadata returns the transaction type and transaction size with the // given transaction hash. @@ -97,6 +98,16 @@ type txPool interface { FilterType(kind byte) bool } +// blobPool defines the methods needed from a blob pool implementation to +// support cell-based blob data availability. +type blobPool interface { + Has(hash common.Hash) bool + GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) + ValidateCells([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error + AddPayload([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error + GetCustody(hash common.Hash) *types.CustodyBitmap +} + // handlerConfig is the collection of initialization parameters to create a full // node network handler. type handlerConfig struct { @@ -104,11 +115,13 @@ type handlerConfig struct { Database ethdb.Database // Database for direct sync insertions Chain *core.BlockChain // Blockchain to serve data from TxPool txPool // Transaction pool to propagate from + BlobPool blobPool // Blob pool for cell-based blob data availability Network uint64 // Network identifier to advertise Sync ethconfig.SyncMode // Whether to snap or full sync BloomCache uint64 // Megabytes to alloc for snap sync bloom EventMux *event.TypeMux // Legacy event mux, deprecate for `feed` RequiredBlocks map[uint64]common.Hash // Hard coded map of required block hashes for sync challenges + Custody types.CustodyBitmap } type handler struct { @@ -118,11 +131,13 @@ type handler struct { database ethdb.Database txpool txPool + blobpool blobPool chain *core.BlockChain maxPeers int downloader *downloader.Downloader txFetcher *fetcher.TxFetcher + blobFetcher *fetcher.BlobFetcher peers *peerSet txBroadcastKey [16]byte @@ -154,6 +169,7 @@ func newHandler(config *handlerConfig) (*handler, error) { eventMux: config.EventMux, database: config.Database, txpool: config.TxPool, + blobpool: config.BlobPool, chain: config.Chain, peers: newPeerSet(), txBroadcastKey: newBroadcastChoiceKey(), @@ -189,6 +205,16 @@ func newHandler(config *handlerConfig) (*handler, error) { return nil } h.txFetcher = fetcher.NewTxFetcher(h.chain, validateMeta, addTxs, fetchTx, h.removePeer) + + // Construct the blob fetcher for cell-based blob data availability + fetchPayloads := func(peer string, hashes []common.Hash, cells *types.CustodyBitmap) error { + p := h.peers.peer(peer) + if p == nil { + return errors.New("unknown peer") + } + return p.RequestPayload(hashes, cells) + } + h.blobFetcher = fetcher.NewBlobFetcher(h.blobpool.ValidateCells, h.blobpool.AddPayload, fetchPayloads, h.removePeer, &config.Custody, nil) return h, nil } @@ -403,6 +429,7 @@ func (h *handler) unregisterPeer(id string) { } h.downloader.UnregisterPeer(id) h.txFetcher.Drop(id) + h.blobFetcher.Drop(id) if err := h.peers.unregisterPeer(id); err != nil { logger.Error("Ethereum peer removal failed", "err", err) @@ -425,6 +452,7 @@ func (h *handler) Start(maxPeers int) { // start sync handlers h.txFetcher.Start() + h.blobFetcher.Start() // start peer handler tracker h.wg.Add(1) @@ -435,6 +463,7 @@ func (h *handler) Stop() { h.txsSub.Unsubscribe() // quits txBroadcastLoop h.blockRange.stop() h.txFetcher.Stop() + h.blobFetcher.Stop() h.downloader.Terminate() // Quit chainSync and txsync64. diff --git a/eth/handler_eth.go b/eth/handler_eth.go index 8704a86af4..c0052d44cc 100644 --- a/eth/handler_eth.go +++ b/eth/handler_eth.go @@ -33,6 +33,7 @@ type ethHandler handler func (h *ethHandler) Chain() *core.BlockChain { return h.chain } func (h *ethHandler) TxPool() eth.TxPool { return h.txpool } +func (h *ethHandler) BlobPool() eth.BlobPool { return h.blobpool } // RunPeer is invoked when a peer joins on the `eth` protocol. func (h *ethHandler) RunPeer(peer *eth.Peer, hand eth.Handler) error { @@ -58,8 +59,19 @@ func (h *ethHandler) AcceptTxs() bool { func (h *ethHandler) Handle(peer *eth.Peer, packet eth.Packet) error { // Consume any broadcasts and announces, forwarding the rest to the downloader switch packet := packet.(type) { - case *eth.NewPooledTransactionHashesPacket: - return h.txFetcher.Notify(peer.ID(), packet.Types, packet.Sizes, packet.Hashes) + case *eth.NewPooledTransactionHashesPacket71: + hashes, err := h.txFetcher.Notify(peer.ID(), packet.Types, packet.Sizes, packet.Hashes) + if err != nil { + return err + } + if len(hashes) != 0 { + return h.blobFetcher.Notify(peer.ID(), hashes, packet.Mask) + } + return nil + + case *eth.NewPooledTransactionHashesPacket70: + _, err := h.txFetcher.Notify(peer.ID(), packet.Types, packet.Sizes, packet.Hashes) + return err case *eth.TransactionsPacket: txs, err := packet.Items() @@ -81,6 +93,9 @@ func (h *ethHandler) Handle(peer *eth.Peer, packet eth.Packet) error { } return h.txFetcher.Enqueue(peer.ID(), txs, true) + case *eth.CellsResponse: + return h.blobFetcher.Enqueue(peer.ID(), packet.Hashes, packet.Cells, packet.Mask) + default: return fmt.Errorf("unexpected eth packet type: %T", packet) } @@ -98,11 +113,17 @@ func handleTransactions(peer *eth.Peer, list []*types.Transaction, directBroadca // If we receive any blob transactions missing sidecars, or with // sidecars that don't correspond to the versioned hashes reported // in the header, disconnect from the sending peer. - if tx.BlobTxSidecar() == nil { - return errors.New("received sidecar-less blob transaction") - } - if err := tx.BlobTxSidecar().ValidateBlobCommitmentHashes(tx.BlobHashes()); err != nil { - return err + if peer.Version() >= eth.ETH71 { + if tx.BlobTxSidecar() != nil && len(tx.BlobTxSidecar().Blobs) != 0 { + return fmt.Errorf("not allowed to respond with full-blob transaction under eth71") + } + } else { + if tx.BlobTxSidecar() == nil { + return errors.New("received sidecar-less blob transaction") + } + if err := tx.BlobTxSidecar().ValidateBlobCommitmentHashes(tx.BlobHashes()); err != nil { + return err + } } } } diff --git a/eth/handler_eth_test.go b/eth/handler_eth_test.go index 68e91fa897..3f219ded56 100644 --- a/eth/handler_eth_test.go +++ b/eth/handler_eth_test.go @@ -44,13 +44,14 @@ type testEthHandler struct { func (h *testEthHandler) Chain() *core.BlockChain { panic("no backing chain") } func (h *testEthHandler) TxPool() eth.TxPool { panic("no backing tx pool") } +func (h *testEthHandler) BlobPool() eth.BlobPool { return nil } func (h *testEthHandler) AcceptTxs() bool { return true } func (h *testEthHandler) RunPeer(*eth.Peer, eth.Handler) error { panic("not used in tests") } func (h *testEthHandler) PeerInfo(enode.ID) interface{} { panic("not used in tests") } func (h *testEthHandler) Handle(peer *eth.Peer, packet eth.Packet) error { switch packet := packet.(type) { - case *eth.NewPooledTransactionHashesPacket: + case *eth.NewPooledTransactionHashesPacket70: h.txAnnounces.Send(packet.Hashes) return nil @@ -105,10 +106,12 @@ func testForkIDSplit(t *testing.T, protocol uint) { _, blocksNoFork, _ = core.GenerateChainWithGenesis(gspecNoFork, engine, 2, nil) _, blocksProFork, _ = core.GenerateChainWithGenesis(gspecProFork, engine, 2, nil) + txPool = newTestTxPool() ethNoFork, _ = newHandler(&handlerConfig{ Database: dbNoFork, Chain: chainNoFork, - TxPool: newTestTxPool(), + TxPool: txPool, + BlobPool: txPool, Network: 1, Sync: ethconfig.FullSync, BloomCache: 1, @@ -116,7 +119,8 @@ func testForkIDSplit(t *testing.T, protocol uint) { ethProFork, _ = newHandler(&handlerConfig{ Database: dbProFork, Chain: chainProFork, - TxPool: newTestTxPool(), + TxPool: txPool, + BlobPool: txPool, Network: 1, Sync: ethconfig.FullSync, BloomCache: 1, @@ -137,8 +141,8 @@ func testForkIDSplit(t *testing.T, protocol uint) { defer p2pNoFork.Close() defer p2pProFork.Close() - peerNoFork := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pNoFork), p2pNoFork, nil) - peerProFork := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pProFork), p2pProFork, nil) + peerNoFork := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pNoFork), p2pNoFork, nil, nil) + peerProFork := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pProFork), p2pProFork, nil, nil) defer peerNoFork.Close() defer peerProFork.Close() @@ -168,8 +172,8 @@ func testForkIDSplit(t *testing.T, protocol uint) { defer p2pNoFork.Close() defer p2pProFork.Close() - peerNoFork = eth.NewPeer(protocol, p2p.NewPeer(enode.ID{1}, "", nil), p2pNoFork, nil) - peerProFork = eth.NewPeer(protocol, p2p.NewPeer(enode.ID{2}, "", nil), p2pProFork, nil) + peerNoFork = eth.NewPeer(protocol, p2p.NewPeer(enode.ID{1}, "", nil), p2pNoFork, nil, nil) + peerProFork = eth.NewPeer(protocol, p2p.NewPeer(enode.ID{2}, "", nil), p2pProFork, nil, nil) defer peerNoFork.Close() defer peerProFork.Close() @@ -199,8 +203,8 @@ func testForkIDSplit(t *testing.T, protocol uint) { defer p2pNoFork.Close() defer p2pProFork.Close() - peerNoFork = eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pNoFork), p2pNoFork, nil) - peerProFork = eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pProFork), p2pProFork, nil) + peerNoFork = eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pNoFork), p2pNoFork, nil, nil) + peerProFork = eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pProFork), p2pProFork, nil, nil) defer peerNoFork.Close() defer peerProFork.Close() @@ -249,8 +253,8 @@ func testRecvTransactions(t *testing.T, protocol uint) { defer p2pSrc.Close() defer p2pSink.Close() - src := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pSrc), p2pSrc, handler.txpool) - sink := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pSink), p2pSink, handler.txpool) + src := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pSrc), p2pSrc, handler.txpool, handler.txpool) + sink := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pSink), p2pSink, handler.txpool, handler.txpool) defer src.Close() defer sink.Close() @@ -305,8 +309,8 @@ func testSendTransactions(t *testing.T, protocol uint) { defer p2pSrc.Close() defer p2pSink.Close() - src := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pSrc), p2pSrc, handler.txpool) - sink := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pSink), p2pSink, handler.txpool) + src := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{1}, "", nil, p2pSrc), p2pSrc, handler.txpool, handler.blobpool) + sink := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{2}, "", nil, p2pSink), p2pSink, handler.txpool, handler.blobpool) defer src.Close() defer sink.Close() @@ -380,8 +384,8 @@ func testTransactionPropagation(t *testing.T, protocol uint) { defer sourcePipe.Close() defer sinkPipe.Close() - sourcePeer := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{byte(i + 1)}, "", nil, sourcePipe), sourcePipe, source.txpool) - sinkPeer := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{0}, "", nil, sinkPipe), sinkPipe, sink.txpool) + sourcePeer := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{byte(i + 1)}, "", nil, sourcePipe), sourcePipe, source.txpool, source.txpool) + sinkPeer := eth.NewPeer(protocol, p2p.NewPeerPipe(enode.ID{0}, "", nil, sinkPipe), sinkPipe, sink.txpool, sink.txpool) defer sourcePeer.Close() defer sinkPeer.Close() diff --git a/eth/handler_test.go b/eth/handler_test.go index fee6bae138..8839e15019 100644 --- a/eth/handler_test.go +++ b/eth/handler_test.go @@ -17,6 +17,7 @@ package eth import ( + "errors" "maps" "math/big" "math/rand" @@ -31,6 +32,7 @@ import ( "github.com/ethereum/go-ethereum/core/txpool" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/eth/ethconfig" "github.com/ethereum/go-ethereum/eth/protocols/eth" "github.com/ethereum/go-ethereum/ethdb" @@ -54,7 +56,10 @@ var ( // Its goal is to get around setting up a valid statedb for the balance and nonce // checks. type testTxPool struct { - pool map[common.Hash]*types.Transaction // Hash map of collected transactions + txPool map[common.Hash]*types.Transaction // Hash map of collected transactions + cellPool map[common.Hash][]kzg4844.Cell + + custody map[common.Hash]types.CustodyBitmap txFeed event.Feed // Notification feed to allow waiting for inclusion lock sync.RWMutex // Protects the transaction pool @@ -63,7 +68,9 @@ type testTxPool struct { // newTestTxPool creates a mock transaction pool. func newTestTxPool() *testTxPool { return &testTxPool{ - pool: make(map[common.Hash]*types.Transaction), + txPool: make(map[common.Hash]*types.Transaction), + cellPool: make(map[common.Hash][]kzg4844.Cell), + custody: make(map[common.Hash]types.CustodyBitmap), } } @@ -73,7 +80,7 @@ func (p *testTxPool) Has(hash common.Hash) bool { p.lock.Lock() defer p.lock.Unlock() - return p.pool[hash] != nil + return p.txPool[hash] != nil } // Get retrieves the transaction from local txpool with given @@ -81,16 +88,16 @@ func (p *testTxPool) Has(hash common.Hash) bool { func (p *testTxPool) Get(hash common.Hash) *types.Transaction { p.lock.Lock() defer p.lock.Unlock() - return p.pool[hash] + return p.txPool[hash] } // Get retrieves the transaction from local txpool with given // tx hash. -func (p *testTxPool) GetRLP(hash common.Hash) []byte { +func (p *testTxPool) GetRLP(hash common.Hash, includeBlob bool) []byte { p.lock.Lock() defer p.lock.Unlock() - tx := p.pool[hash] + tx := p.txPool[hash] if tx != nil { blob, _ := rlp.EncodeToBytes(tx) return blob @@ -104,7 +111,7 @@ func (p *testTxPool) GetMetadata(hash common.Hash) *txpool.TxMetadata { p.lock.Lock() defer p.lock.Unlock() - tx := p.pool[hash] + tx := p.txPool[hash] if tx != nil { return &txpool.TxMetadata{ Type: tx.Type(), @@ -121,7 +128,7 @@ func (p *testTxPool) Add(txs []*types.Transaction, sync bool) []error { defer p.lock.Unlock() for _, tx := range txs { - p.pool[tx.Hash()] = tx + p.txPool[tx.Hash()] = tx } p.txFeed.Send(core.NewTxsEvent{Txs: txs}) return make([]error, len(txs)) @@ -134,7 +141,7 @@ func (p *testTxPool) Pending(filter txpool.PendingFilter) (map[common.Address][] var count int batches := make(map[common.Address][]*types.Transaction) - for _, tx := range p.pool { + for _, tx := range p.txPool { from, _ := types.Sender(types.HomesteadSigner{}, tx) batches[from] = append(batches[from], tx) } @@ -164,6 +171,68 @@ func (p *testTxPool) Pending(filter txpool.PendingFilter) (map[common.Address][] func (p *testTxPool) SubscribeTransactions(ch chan<- core.NewTxsEvent, reorgs bool) event.Subscription { return p.txFeed.Subscribe(ch) } +func (p *testTxPool) GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) { + p.lock.RLock() + defer p.lock.RUnlock() + + _, exists := p.txPool[hash] + if !exists { + return nil, errors.New("Requested tx does not exist") + } + + var cells []kzg4844.Cell + + if cells, exists = p.cellPool[hash]; !exists { + return nil, errors.New("Requested cells do not exist") + } + + result := make([]kzg4844.Cell, 0, mask.OneCount()) + for _, idx := range mask.Indices() { + if int(idx) < len(cells) { + result = append(result, cells[idx]) + } + } + return result, nil +} + +func (p *testTxPool) GetCustody(hash common.Hash) *types.CustodyBitmap { + p.lock.RLock() + defer p.lock.RUnlock() + mask, ok := p.custody[hash] + if !ok { + return nil + } + return &mask +} + +// AddCells adds cells for a specific transaction hash (for testing) +func (p *testTxPool) AddCells(hash common.Hash, cells []kzg4844.Cell, mask types.CustodyBitmap) { + p.lock.Lock() + defer p.lock.Unlock() + p.cellPool[hash] = cells + p.custody[hash] = mask +} + +func (p *testTxPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { + p.lock.Lock() + defer p.lock.Unlock() + + for i, tx := range txs { + p.cellPool[tx] = cells[i] + } + return nil +} + +func (p *testTxPool) ValidateCells(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { + p.lock.Lock() + defer p.lock.Unlock() + + errors := make([]error, len(txs)) + for i, tx := range txs { + errors[i] = kzg4844.VerifyCells(cells[i], p.txPool[tx].BlobTxSidecar().Commitments, p.txPool[tx].BlobTxSidecar().Proofs, custody.Indices()) + } + return errors +} // FilterType should check whether the pool supports the given type of transactions. func (p *testTxPool) FilterType(kind byte) bool { @@ -178,10 +247,11 @@ func (p *testTxPool) FilterType(kind byte) bool { // preinitialized with some sane testing defaults and the transaction pool mocked // out. type testHandler struct { - db ethdb.Database - chain *core.BlockChain - txpool *testTxPool - handler *handler + db ethdb.Database + chain *core.BlockChain + txpool *testTxPool + blobpool *testTxPool + handler *handler } // newTestHandler creates a new handler for testing purposes with no blocks. @@ -210,6 +280,7 @@ func newTestHandlerWithBlocks(blocks int, mode ethconfig.SyncMode) *testHandler Database: db, Chain: chain, TxPool: txpool, + BlobPool: txpool, Network: 1, Sync: mode, BloomCache: 1, @@ -217,10 +288,11 @@ func newTestHandlerWithBlocks(blocks int, mode ethconfig.SyncMode) *testHandler handler.Start(1000) return &testHandler{ - db: db, - chain: chain, - txpool: txpool, - handler: handler, + db: db, + chain: chain, + txpool: txpool, + blobpool: txpool, + handler: handler, } } @@ -317,7 +389,7 @@ func createTestPeers(rand *rand.Rand, n int) []*ethPeer { var id enode.ID rand.Read(id[:]) p2pPeer := p2p.NewPeer(id, "test", nil) - ep := eth.NewPeer(eth.ETH69, p2pPeer, nil, nil) + ep := eth.NewPeer(eth.ETH69, p2pPeer, nil, nil, nil) peers[i] = ðPeer{Peer: ep} } return peers diff --git a/eth/protocols/eth/broadcast.go b/eth/protocols/eth/broadcast.go index 21cea0d4ef..8944b0d7f7 100644 --- a/eth/protocols/eth/broadcast.go +++ b/eth/protocols/eth/broadcast.go @@ -113,29 +113,51 @@ func (p *Peer) announceTransactions() { pending []common.Hash pendingTypes []byte pendingSizes []uint32 + mask types.CustodyBitmap size common.StorageSize + processed = make(map[int]bool) ) for count = 0; count < len(queue) && size < maxTxPacketSize; count++ { if meta := p.txpool.GetMetadata(queue[count]); meta != nil { + custody := p.blobpool.GetCustody(queue[count]) + if custody != nil { + // blob tx + if mask.OneCount() == 0 { + mask = *custody + } else { + if mask != *custody { + // group by mask + continue + } + } + } pending = append(pending, queue[count]) pendingTypes = append(pendingTypes, meta.Type) pendingSizes = append(pendingSizes, uint32(meta.Size)) size += common.HashLength + + processed[count] = true } } - // Shift and trim queue - queue = queue[:copy(queue, queue[count:])] + // Shift and trim queue using processed map + var remaining []common.Hash + for i, h := range queue { + if !processed[i] { + remaining = append(remaining, h) + } + } + queue = remaining // If there's anything available to transfer, fire up an async writer if len(pending) > 0 { done = make(chan struct{}) go func() { - if err := p.sendPooledTransactionHashes(pending, pendingTypes, pendingSizes); err != nil { + if err := p.sendPooledTransactionHashes(pending, pendingTypes, pendingSizes, mask); err != nil { fail <- err return } close(done) - p.Log().Trace("Sent transaction announcements", "count", len(pending)) + p.Log().Trace("Sent transaction announcements", "count", len(pending), "mask", mask, "tx", pending) }() } } diff --git a/eth/protocols/eth/handler.go b/eth/protocols/eth/handler.go index aa1c7d45bc..f5f4cfb34c 100644 --- a/eth/protocols/eth/handler.go +++ b/eth/protocols/eth/handler.go @@ -24,6 +24,7 @@ import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/txpool" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/metrics" "github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/p2p/enode" @@ -64,6 +65,9 @@ type Backend interface { // TxPool retrieves the transaction pool object to serve data. TxPool() TxPool + // BlobPool retrieves the blob pool object to serve cell requests. + BlobPool() BlobPool + // AcceptTxs retrieves whether transaction processing is enabled on the node // or if inbound transactions should simply be dropped. AcceptTxs() bool @@ -83,6 +87,16 @@ type Backend interface { Handle(peer *Peer, packet Packet) error } +// BlobPool defines the methods needed by the protocol handler to serve cell requests. +type BlobPool interface { + // GetCells retrieves cells for a given transaction hash filtered by the custody bitmap. + GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) + // GetCustody returns the custody bitmap for a given transaction hash. + GetCustody(hash common.Hash) *types.CustodyBitmap + // Has returns whether the blob pool contains a transaction with the given hash. + Has(hash common.Hash) bool +} + // TxPool defines the methods needed by the protocol handler to serve transactions. type TxPool interface { // Get retrieves the transaction from the local txpool with the given hash. @@ -90,7 +104,7 @@ type TxPool interface { // GetRLP retrieves the RLP-encoded transaction from the local txpool with // the given hash. - GetRLP(hash common.Hash) []byte + GetRLP(hash common.Hash, includeBlob bool) []byte // GetMetadata returns the transaction type and transaction size with the // given transaction hash. @@ -106,7 +120,7 @@ func MakeProtocols(backend Backend, network uint64, disc enode.Iterator) []p2p.P Version: version, Length: protocolLengths[version], Run: func(p *p2p.Peer, rw p2p.MsgReadWriter) error { - peer := NewPeer(version, p, rw, backend.TxPool()) + peer := NewPeer(version, p, rw, backend.TxPool(), backend.BlobPool()) defer peer.Close() return backend.RunPeer(peer, func(peer *Peer) error { @@ -180,6 +194,22 @@ var eth69 = map[uint64]msgHandler{ BlockRangeUpdateMsg: handleBlockRangeUpdate, } +var eth71 = map[uint64]msgHandler{ + TransactionsMsg: handleTransactions, + NewPooledTransactionHashesMsg: handleNewPooledTransactionHashes71, + GetBlockHeadersMsg: handleGetBlockHeaders, + BlockHeadersMsg: handleBlockHeaders, + GetBlockBodiesMsg: handleGetBlockBodies, + BlockBodiesMsg: handleBlockBodies, + GetReceiptsMsg: handleGetReceipts, + ReceiptsMsg: handleReceipts, + GetPooledTransactionsMsg: handleGetPooledTransactions, + PooledTransactionsMsg: handlePooledTransactions, + BlockRangeUpdateMsg: handleBlockRangeUpdate, + GetCellsMsg: handleGetCells, + CellsMsg: handleCells, +} + // handleMessage is invoked whenever an inbound message is received from a remote // peer. The remote connection is torn down upon returning any error. func handleMessage(backend Backend, peer *Peer) error { @@ -194,9 +224,12 @@ func handleMessage(backend Backend, peer *Peer) error { defer msg.Discard() var handlers map[uint64]msgHandler - if peer.version == ETH69 { + switch peer.version { + case ETH69: handlers = eth69 - } else { + case ETH71: + handlers = eth71 + default: return fmt.Errorf("unknown eth protocol version: %v", peer.version) } diff --git a/eth/protocols/eth/handler_test.go b/eth/protocols/eth/handler_test.go index 2e0ce0408b..6ab116f760 100644 --- a/eth/protocols/eth/handler_test.go +++ b/eth/protocols/eth/handler_test.go @@ -62,9 +62,10 @@ func u64(val uint64) *uint64 { return &val } // purpose is to allow testing the request/reply workflows and wire serialization // in the `eth` protocol without actually doing any data processing. type testBackend struct { - db ethdb.Database - chain *core.BlockChain - txpool *txpool.TxPool + db ethdb.Database + chain *core.BlockChain + txpool *txpool.TxPool + blobpool *blobpool.BlobPool } // newTestBackend creates an empty chain and wraps it into a mock backend. @@ -142,9 +143,10 @@ func newTestBackendWithGenerator(blocks int, shanghai bool, cancun bool, generat txpool, _ := txpool.New(txconfig.PriceLimit, chain, []txpool.SubPool{legacyPool, blobPool}) return &testBackend{ - db: db, - chain: chain, - txpool: txpool, + db: db, + chain: chain, + txpool: txpool, + blobpool: blobPool, } } @@ -156,6 +158,7 @@ func (b *testBackend) close() { func (b *testBackend) Chain() *core.BlockChain { return b.chain } func (b *testBackend) TxPool() TxPool { return b.txpool } +func (b *testBackend) BlobPool() BlobPool { return b.blobpool } func (b *testBackend) RunPeer(peer *Peer, handler Handler) error { // Normally the backend would do peer maintenance and handshakes. All that diff --git a/eth/protocols/eth/handlers.go b/eth/protocols/eth/handlers.go index 90717472f9..81afd9aaee 100644 --- a/eth/protocols/eth/handlers.go +++ b/eth/protocols/eth/handlers.go @@ -26,6 +26,7 @@ import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/p2p/tracker" "github.com/ethereum/go-ethereum/rlp" @@ -482,7 +483,27 @@ func handleNewPooledTransactionHashes(backend Backend, msg Decoder, peer *Peer) if !backend.AcceptTxs() { return nil } - ann := new(NewPooledTransactionHashesPacket) + ann := new(NewPooledTransactionHashesPacket70) + if err := msg.Decode(ann); err != nil { + return err + } + if len(ann.Hashes) != len(ann.Types) || len(ann.Hashes) != len(ann.Sizes) { + return fmt.Errorf("NewPooledTransactionHashes: invalid len of fields in %v %v %v", len(ann.Hashes), len(ann.Types), len(ann.Sizes)) + } + // Schedule all the unknown hashes for retrieval + for _, hash := range ann.Hashes { + peer.MarkTransaction(hash) + } + return backend.Handle(peer, ann) +} + +func handleNewPooledTransactionHashes71(backend Backend, msg Decoder, peer *Peer) error { + // New transaction announcement arrived, make sure we have + // a valid and fresh chain to handle them + if !backend.AcceptTxs() { + return nil + } + ann := new(NewPooledTransactionHashesPacket71) if err := msg.Decode(ann); err != nil { return err } @@ -502,11 +523,11 @@ func handleGetPooledTransactions(backend Backend, msg Decoder, peer *Peer) error if err := msg.Decode(&query); err != nil { return err } - hashes, txs := answerGetPooledTransactions(backend, query.GetPooledTransactionsRequest) + hashes, txs := answerGetPooledTransactions(backend, query.GetPooledTransactionsRequest, peer.version < ETH71) return peer.ReplyPooledTransactionsRLP(query.RequestId, hashes, txs) } -func answerGetPooledTransactions(backend Backend, query GetPooledTransactionsRequest) ([]common.Hash, []rlp.RawValue) { +func answerGetPooledTransactions(backend Backend, query GetPooledTransactionsRequest, includeBlob bool) ([]common.Hash, []rlp.RawValue) { // Gather transactions until the fetch or network limits is reached var ( bytes int @@ -518,7 +539,7 @@ func answerGetPooledTransactions(backend Backend, query GetPooledTransactionsReq break } // Retrieve the requested transaction, skipping if unknown to us - encoded := backend.TxPool().GetRLP(hash) + encoded := backend.TxPool().GetRLP(hash, includeBlob) if len(encoded) == 0 { continue } @@ -580,3 +601,52 @@ func handleBlockRangeUpdate(backend Backend, msg Decoder, peer *Peer) error { peer.lastRange.Store(&update) return nil } + +func handleGetCells(backend Backend, msg Decoder, peer *Peer) error { + // Decode the cell retrieval message + var query GetCellsRequestPacket + if err := msg.Decode(&query); err != nil { + return err + } + hashes, cells, custody := answerGetCells(backend, query.GetCellsRequest) + return peer.ReplyCells(query.RequestId, hashes, cells, custody) +} + +func answerGetCells(backend Backend, query GetCellsRequest) ([]common.Hash, [][]kzg4844.Cell, types.CustodyBitmap) { + var ( + cellCounts int + hashes []common.Hash + cells [][]kzg4844.Cell + ) + maxCells := softResponseLimit / 2048 + for _, hash := range query.Hashes { + if cellCounts >= maxCells { + break + } + cell, _ := backend.BlobPool().GetCells(hash, query.Mask) + if len(cell) == 0 { + // skip this tx + continue + } + hashes = append(hashes, hash) + cells = append(cells, cell) + cellCounts += len(cell) + } + return hashes, cells, query.Mask +} + +func handleCells(backend Backend, msg Decoder, peer *Peer) error { + var cellsResponse CellsPacket + if err := msg.Decode(&cellsResponse); err != nil { + return err + } + tresp := tracker.Response{ + ID: cellsResponse.RequestId, + MsgCode: CellsMsg, + Size: len(cellsResponse.CellsResponse.Hashes), + } + if err := peer.tracker.Fulfil(tresp); err != nil { + return fmt.Errorf("Cells: %w", err) + } + return backend.Handle(peer, &cellsResponse.CellsResponse) +} diff --git a/eth/protocols/eth/handshake_test.go b/eth/protocols/eth/handshake_test.go index e2f1e7592a..5746d5896d 100644 --- a/eth/protocols/eth/handshake_test.go +++ b/eth/protocols/eth/handshake_test.go @@ -77,7 +77,7 @@ func testHandshake(t *testing.T, protocol uint) { defer app.Close() defer net.Close() - peer := NewPeer(protocol, p2p.NewPeer(enode.ID{}, "peer", nil), net, nil) + peer := NewPeer(protocol, p2p.NewPeer(enode.ID{}, "peer", nil), net, nil, nil) defer peer.Close() // Send the junk test with one peer, check the handshake failure diff --git a/eth/protocols/eth/peer.go b/eth/protocols/eth/peer.go index 3c6d58d670..b6f88ed4d2 100644 --- a/eth/protocols/eth/peer.go +++ b/eth/protocols/eth/peer.go @@ -24,6 +24,7 @@ import ( mapset "github.com/deckarep/golang-set/v2" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/p2p/tracker" "github.com/ethereum/go-ethereum/rlp" @@ -53,7 +54,8 @@ type Peer struct { version uint // Protocol version negotiated lastRange atomic.Pointer[BlockRangeUpdatePacket] - txpool TxPool // Transaction pool used by the broadcasters for liveness checks + txpool TxPool // Transaction pool used by the broadcasters for liveness checks + blobpool BlobPool knownTxs *knownCache // Set of transaction hashes known to be known by this peer txBroadcast chan []common.Hash // Channel used to queue transaction propagation requests txAnnounce chan []common.Hash // Channel used to queue transaction announcement requests @@ -68,7 +70,7 @@ type Peer struct { // NewPeer creates a wrapper for a network connection and negotiated protocol // version. -func NewPeer(version uint, p *p2p.Peer, rw p2p.MsgReadWriter, txpool TxPool) *Peer { +func NewPeer(version uint, p *p2p.Peer, rw p2p.MsgReadWriter, txpool TxPool, blobpool BlobPool) *Peer { cap := p2p.Cap{Name: ProtocolName, Version: version} id := p.ID().String() peer := &Peer{ @@ -84,6 +86,7 @@ func NewPeer(version uint, p *p2p.Peer, rw p2p.MsgReadWriter, txpool TxPool) *Pe reqCancel: make(chan *cancel), resDispatch: make(chan *response), txpool: txpool, + blobpool: blobpool, term: make(chan struct{}), } // Start up all the broadcasters @@ -166,10 +169,13 @@ func (p *Peer) AsyncSendTransactions(hashes []common.Hash) { // This method is a helper used by the async transaction announcer. Don't call it // directly as the queueing (memory) and transmission (bandwidth) costs should // not be managed directly. -func (p *Peer) sendPooledTransactionHashes(hashes []common.Hash, types []byte, sizes []uint32) error { +func (p *Peer) sendPooledTransactionHashes(hashes []common.Hash, types []byte, sizes []uint32, cells types.CustodyBitmap) error { // Mark all the transactions as known, but ensure we don't overflow our limits p.knownTxs.Add(hashes...) - return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket{Types: types, Sizes: sizes, Hashes: hashes}) + if p.version >= ETH71 { + return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket71{Types: types, Sizes: sizes, Hashes: hashes, Mask: cells}) + } + return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket70{Types: types, Sizes: sizes, Hashes: hashes}) } // AsyncSendPooledTransactionHashes queues a list of transactions hashes to eventually @@ -222,6 +228,41 @@ func (p *Peer) ReplyReceiptsRLP(id uint64, receipts rlp.RawList[*ReceiptList]) e }) } +// ReplyCells is the response to GetCells. +func (p *Peer) ReplyCells(id uint64, hashes []common.Hash, cells [][]kzg4844.Cell, mask types.CustodyBitmap) error { + return p2p.Send(p.rw, CellsMsg, &CellsPacket{ + RequestId: id, + CellsResponse: CellsResponse{ + Hashes: hashes, + Cells: cells, + Mask: mask, + }, + }) +} + +// RequestPayload fetches a batch of cells from a remote node. +func (p *Peer) RequestPayload(hashes []common.Hash, cell *types.CustodyBitmap) error { + p.Log().Debug("Fetching batch of cells", "txcount", len(hashes), "cellcount", cell.OneCount()) + id := rand.Uint64() + + err := p.tracker.Track(tracker.Request{ + ID: id, + ReqCode: GetCellsMsg, + RespCode: CellsMsg, + Size: len(hashes), + }) + if err != nil { + return err + } + return p2p.Send(p.rw, GetCellsMsg, &GetCellsRequestPacket{ + RequestId: id, + GetCellsRequest: GetCellsRequest{ + Hashes: hashes, + Mask: *cell, + }, + }) +} + // RequestOneHeader is a wrapper around the header query functions to fetch a // single header. It is used solely by the fetcher. func (p *Peer) RequestOneHeader(hash common.Hash, sink chan *Response) (*Request, error) { diff --git a/eth/protocols/eth/peer_test.go b/eth/protocols/eth/peer_test.go index efbbbc6fff..29390aa12e 100644 --- a/eth/protocols/eth/peer_test.go +++ b/eth/protocols/eth/peer_test.go @@ -45,7 +45,7 @@ func newTestPeer(name string, version uint, backend Backend) (*testPeer, <-chan var id enode.ID rand.Read(id[:]) - peer := NewPeer(version, p2p.NewPeer(id, name, nil), net, backend.TxPool()) + peer := NewPeer(version, p2p.NewPeer(id, name, nil), net, backend.TxPool(), backend.BlobPool()) errc := make(chan error, 1) go func() { defer app.Close() diff --git a/eth/protocols/eth/protocol.go b/eth/protocols/eth/protocol.go index ef65a7d034..d1aafa34f1 100644 --- a/eth/protocols/eth/protocol.go +++ b/eth/protocols/eth/protocol.go @@ -24,12 +24,14 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/forkid" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/rlp" ) // Constants to match up protocol versions and messages const ( ETH69 = 69 + ETH71 = 71 ) // ProtocolName is the official short name of the `eth` protocol used during @@ -38,11 +40,11 @@ const ProtocolName = "eth" // ProtocolVersions are the supported versions of the `eth` protocol (first // is primary). -var ProtocolVersions = []uint{ETH69} +var ProtocolVersions = []uint{ETH71, ETH69} // protocolLengths are the number of implemented message corresponding to // different protocol versions. -var protocolLengths = map[uint]uint64{ETH69: 18} +var protocolLengths = map[uint]uint64{ETH69: 18, ETH71: 20} // maxMessageSize is the maximum cap on the size of a protocol message. const maxMessageSize = 10 * 1024 * 1024 @@ -65,6 +67,8 @@ const ( GetReceiptsMsg = 0x0f ReceiptsMsg = 0x10 BlockRangeUpdateMsg = 0x11 + GetCellsMsg = 0x12 + CellsMsg = 0x13 ) var ( @@ -230,13 +234,22 @@ type ReceiptsPacket struct { // ReceiptsRLPResponse is used for receipts, when we already have it encoded type ReceiptsRLPResponse []rlp.RawValue -// NewPooledTransactionHashesPacket represents a transaction announcement packet on eth/68 and newer. -type NewPooledTransactionHashesPacket struct { +// NewPooledTransactionHashesPacket70 represents a transaction announcement packet on eth/69. +type NewPooledTransactionHashesPacket70 struct { Types []byte Sizes []uint32 Hashes []common.Hash } +// NewPooledTransactionHashesPacket71 represents a transaction announcement packet on eth/71 +// with an additional custody bitmap field for cell-based blob data availability. +type NewPooledTransactionHashesPacket71 struct { + Types []byte + Sizes []uint32 + Hashes []common.Hash + Mask types.CustodyBitmap +} + // GetPooledTransactionsRequest represents a transaction query. type GetPooledTransactionsRequest []common.Hash @@ -273,6 +286,31 @@ type BlockRangeUpdatePacket struct { LatestBlockHash common.Hash } +// GetCellsRequest represents a request for cells of blob transactions. +type GetCellsRequest struct { + Hashes []common.Hash + Mask types.CustodyBitmap +} + +// GetCellsRequestPacket represents a cell request with request ID wrapping. +type GetCellsRequestPacket struct { + RequestId uint64 + GetCellsRequest +} + +// CellsResponse represents a response containing cells for blob transactions. +type CellsResponse struct { + Hashes []common.Hash + Cells [][]kzg4844.Cell + Mask types.CustodyBitmap +} + +// CellsPacket represents a cells response with request ID wrapping. +type CellsPacket struct { + RequestId uint64 + CellsResponse +} + func (*StatusPacket) Name() string { return "Status" } func (*StatusPacket) Kind() byte { return StatusMsg } @@ -291,8 +329,11 @@ func (*GetBlockBodiesRequest) Kind() byte { return GetBlockBodiesMsg } func (*BlockBodiesResponse) Name() string { return "BlockBodies" } func (*BlockBodiesResponse) Kind() byte { return BlockBodiesMsg } -func (*NewPooledTransactionHashesPacket) Name() string { return "NewPooledTransactionHashes" } -func (*NewPooledTransactionHashesPacket) Kind() byte { return NewPooledTransactionHashesMsg } +func (*NewPooledTransactionHashesPacket70) Name() string { return "NewPooledTransactionHashes" } +func (*NewPooledTransactionHashesPacket70) Kind() byte { return NewPooledTransactionHashesMsg } + +func (*NewPooledTransactionHashesPacket71) Name() string { return "NewPooledTransactionHashes" } +func (*NewPooledTransactionHashesPacket71) Kind() byte { return NewPooledTransactionHashesMsg } func (*GetPooledTransactionsRequest) Name() string { return "GetPooledTransactions" } func (*GetPooledTransactionsRequest) Kind() byte { return GetPooledTransactionsMsg } @@ -311,3 +352,9 @@ func (*ReceiptsRLPResponse) Kind() byte { return ReceiptsMsg } func (*BlockRangeUpdatePacket) Name() string { return "BlockRangeUpdate" } func (*BlockRangeUpdatePacket) Kind() byte { return BlockRangeUpdateMsg } + +func (*GetCellsRequest) Name() string { return "GetCells" } +func (*GetCellsRequest) Kind() byte { return GetCellsMsg } + +func (*CellsResponse) Name() string { return "Cells" } +func (*CellsResponse) Kind() byte { return CellsMsg } diff --git a/eth/sync_test.go b/eth/sync_test.go index 77a50bf6d3..cafdbddc26 100644 --- a/eth/sync_test.go +++ b/eth/sync_test.go @@ -50,8 +50,8 @@ func testSnapSyncDisabling(t *testing.T, ethVer uint, snapVer uint) { defer emptyPipeEth.Close() defer fullPipeEth.Close() - emptyPeerEth := eth.NewPeer(ethVer, p2p.NewPeer(enode.ID{1}, "", caps), emptyPipeEth, empty.txpool) - fullPeerEth := eth.NewPeer(ethVer, p2p.NewPeer(enode.ID{2}, "", caps), fullPipeEth, full.txpool) + emptyPeerEth := eth.NewPeer(ethVer, p2p.NewPeer(enode.ID{1}, "", caps), emptyPipeEth, empty.txpool, empty.blobpool) + fullPeerEth := eth.NewPeer(ethVer, p2p.NewPeer(enode.ID{2}, "", caps), fullPipeEth, full.txpool, full.blobpool) defer emptyPeerEth.Close() defer fullPeerEth.Close() diff --git a/tests/fuzzers/txfetcher/txfetcher_fuzzer.go b/tests/fuzzers/txfetcher/txfetcher_fuzzer.go index bcceaff383..872573d40f 100644 --- a/tests/fuzzers/txfetcher/txfetcher_fuzzer.go +++ b/tests/fuzzers/txfetcher/txfetcher_fuzzer.go @@ -139,7 +139,7 @@ func fuzz(input []byte) int { if verbose { fmt.Println("Notify", peer, announceIdxs) } - if err := f.Notify(peer, types, sizes, announces); err != nil { + if _, err := f.Notify(peer, types, sizes, announces); err != nil { panic(err) } From c4ea820d2720ac581b3305693257f5dd81cc0758 Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 19 Mar 2026 13:00:42 +0900 Subject: [PATCH 02/24] better comments for blob fetcher --- eth/fetcher/blob_fetcher.go | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index 6f030231bf..7b01d4beca 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -103,18 +103,18 @@ type BlobFetcher struct { full map[common.Hash]struct{} partial map[common.Hash]struct{} - // Buffer 1: Set of blob txs whose blob data is waiting for availability confirmation (not pull decision) + // Buffer 1: Set of blob txs whose blob data is waiting for availability confirmation (partial fetch) waitlist map[common.Hash]map[string]struct{} // Peer set that announced blob availability waittime map[common.Hash]mclock.AbsTime // Timestamp when added to waitlist waitslots map[string]map[common.Hash]struct{} // Waiting announcements grouped by peer (DoS protection) // waitSlots should also include announcements with partial cells - // Buffer 2: Transactions queued for fetching (pull decision + not pull decision) + // Buffer 2: Transactions queued for fetching (full fetch + partial fetch) // "announces" is shared with stage 3, for DoS protection announces map[string]map[common.Hash]*cellWithSeq // Set of announced transactions, grouped by origin peer // Buffer 2 - // Stage 3: Transactions whose payloads/cells are currently being fetched (pull decision + not pull decision) + // Stage 3: Transactions whose payloads/cells are currently being fetched (full fetch + partial fetch) fetches map[common.Hash]*fetchStatus // Hash -> Bitmap, in-flight transaction cells requests map[string][]*cellRequest // In-flight transaction retrievals // todo simplify @@ -219,7 +219,6 @@ func (f *BlobFetcher) loop() { case ann := <-f.notify: // Drop part of the announcements if too many have accumulated from that peer // This prevents a peer from dominating the queue with txs without responding to the request - // todo maxPayloadAnnounces -> according to the number of blobs used := len(f.waitslots[ann.origin]) + len(f.announces[ann.origin]) if used >= maxPayloadAnnounces { // Already full @@ -289,6 +288,7 @@ func (f *BlobFetcher) loop() { // 2) Decided to send partial request of the tx if f.waitlist[hash] != nil { if ann.cells != *types.CustodyBitmapAll { + // Availability check is only meaningful with full availability announcements continue } // Transaction is at the stage of availability check @@ -302,6 +302,7 @@ func (f *BlobFetcher) loop() { } } if len(f.waitlist[hash]) >= availabilityThreshold { + // Passed availability check, move to fetching stage for peer := range f.waitlist[hash] { if f.announces[peer] == nil { f.announces[peer] = make(map[common.Hash]*cellWithSeq) @@ -317,14 +318,16 @@ func (f *BlobFetcher) loop() { reschedule[peer] = struct{}{} } delete(f.waitlist, hash) + //todo delete(f.waittime, hash) } continue } if ann.cells.Intersection(f.custody).OneCount() == 0 { - // No custody overlapping + // If there's no custody overlapping in ann, it can be ignored continue } - + // Add this peer as a possible fetch source + // todo: Did we remove fetch from partial if f.announces[ann.origin] == nil { f.announces[ann.origin] = make(map[common.Hash]*cellWithSeq) } @@ -343,7 +346,6 @@ func (f *BlobFetcher) loop() { // If this is a new peer and that peer sent transaction with payload flag, // schedule transaction fetches from it - //todo if !oldPeer && len(f.announces[ann.origin]) > 0 { f.scheduleFetches(timeoutTimer, timeoutTrigger, reschedule) } @@ -351,10 +353,10 @@ func (f *BlobFetcher) loop() { case <-waitTrigger: // At least one transaction's waiting time ran out, pop all expired ones // and update the blobpool according to availability - // Availability failure case for hash, instance := range f.waittime { if time.Duration(f.clock.Now()-instance)+txGatherSlack > blobAvailabilityTimeout { - // Check if enough peers have announced availability + // No need to check availability count (transactions that passed + // the threshold are already promoted to the announces map on notification) for peer := range f.waitlist[hash] { delete(f.waitslots[peer], hash) if len(f.waitslots[peer]) == 0 { @@ -660,29 +662,29 @@ func (f *BlobFetcher) scheduleFetches(timer *mclock.Timer, timeout chan struct{} custodies = make([]*types.CustodyBitmap, 0, maxTxRetrievals) ) f.forEachAnnounce(f.announces[peer], func(hash common.Hash, cells *types.CustodyBitmap) bool { - var difference *types.CustodyBitmap + var unfetched *types.CustodyBitmap if f.fetches[hash] == nil { // tx is not being fetched - difference = cells + unfetched = cells } else { - difference = cells.Difference(f.fetches[hash].fetching) + unfetched = cells.Difference(f.fetches[hash].fetching) } - // Mark fetching for differences - if difference.OneCount() != 0 { + // Mark fetching for unfetched cells + if unfetched.OneCount() > 0 { if f.fetches[hash] == nil { f.fetches[hash] = &fetchStatus{ - fetching: difference, + fetching: unfetched, fetched: make([]uint64, 0), cells: make([]kzg4844.Cell, 0), } } else { - f.fetches[hash].fetching = f.fetches[hash].fetching.Union(difference) + f.fetches[hash].fetching = f.fetches[hash].fetching.Union(unfetched) } // Accumulate the hash and stop if the limit was reached hashes = append(hashes, hash) - custodies = append(custodies, difference) + custodies = append(custodies, unfetched) } // Mark alternatives From 07d3ae1d80aef8b2499107b24cc4649df5162f81 Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 19 Mar 2026 13:01:30 +0900 Subject: [PATCH 03/24] change requestByCustody to use bitmap as key --- eth/fetcher/blob_fetcher.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index 7b01d4beca..31b9434ad6 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -701,17 +701,14 @@ func (f *BlobFetcher) scheduleFetches(timer *mclock.Timer, timeout chan struct{} // If any hashes were allocated, request them from the peer if len(hashes) > 0 { // Group hashes by custody bitmap - requestByCustody := make(map[string]*cellRequest) + requestByCustody := make(map[types.CustodyBitmap]*cellRequest) for i, hash := range hashes { - custody := custodies[i] - - key := string(custody[:]) - + key := *custodies[i] if _, ok := requestByCustody[key]; !ok { requestByCustody[key] = &cellRequest{ txs: []common.Hash{}, - cells: custody, + cells: custodies[i], time: f.clock.Now(), } } From cc1fde37a4d0d8465f8828ffd4ddb3f494f5699f Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 19 Mar 2026 14:11:54 +0900 Subject: [PATCH 04/24] fix bug in blob fetcher --- eth/fetcher/blob_fetcher.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index 31b9434ad6..05a05fd3fb 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -318,7 +318,7 @@ func (f *BlobFetcher) loop() { reschedule[peer] = struct{}{} } delete(f.waitlist, hash) - //todo delete(f.waittime, hash) + delete(f.waittime, hash) } continue } @@ -419,7 +419,7 @@ func (f *BlobFetcher) loop() { addedCells := make([][]kzg4844.Cell, 0) var requestId int - request := new(cellRequest) + var request *cellRequest for _, hash := range delivery.txs { // Find the request for i, req := range f.requests[delivery.origin] { From 5e9f1a019a3bea4dc5fbf417ddde105da8d86099 Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 19 Mar 2026 15:07:57 +0900 Subject: [PATCH 05/24] add metrics --- eth/fetcher/blob_fetcher.go | 21 ++++++++++++++++++--- eth/fetcher/metrics.go | 3 +-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index 05a05fd3fb..097bc5aa66 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -164,6 +164,7 @@ func NewBlobFetcher( // Notify is called when a Type 3 transaction is observed on the network. (TransactionPacket / NewPooledTransactionHashesPacket) func (f *BlobFetcher) Notify(peer string, txs []common.Hash, cells types.CustodyBitmap) error { + blobAnnounceInMeter.Mark(int64(len(txs))) blobAnnounce := &blobTxAnnounce{origin: peer, txs: txs, cells: cells} select { case f.notify <- blobAnnounce: @@ -196,6 +197,7 @@ func (f *BlobFetcher) Drop(peer string) error { } func (f *BlobFetcher) UpdateCustody(cells types.CustodyBitmap) { + // todo use lock or process inside of loop f.custody = &cells } @@ -221,13 +223,13 @@ func (f *BlobFetcher) loop() { // This prevents a peer from dominating the queue with txs without responding to the request used := len(f.waitslots[ann.origin]) + len(f.announces[ann.origin]) if used >= maxPayloadAnnounces { - // Already full + blobAnnounceDOSMeter.Mark(int64(len(ann.txs))) break } want := used + len(ann.txs) if want >= maxPayloadAnnounces { - // drop part of announcements + blobAnnounceDOSMeter.Mark(int64(want - maxPayloadAnnounces)) ann.txs = ann.txs[:maxPayloadAnnounces-used] } @@ -303,6 +305,7 @@ func (f *BlobFetcher) loop() { } if len(f.waitlist[hash]) >= availabilityThreshold { // Passed availability check, move to fetching stage + blobFetcherWaitTime.Update(int64(time.Duration(f.clock.Now() - f.waittime[hash]))) for peer := range f.waitlist[hash] { if f.announces[peer] == nil { f.announces[peer] = make(map[common.Hash]*cellWithSeq) @@ -381,7 +384,7 @@ func (f *BlobFetcher) loop() { newRequests := make([]*cellRequest, 0) for _, req := range requests { if time.Duration(f.clock.Now()-req.time)+txGatherSlack > blobFetchTimeout { - // Reschedule all timeout cells to alternate peers + blobRequestTimeoutMeter.Mark(int64(len(req.txs))) for _, hash := range req.txs { // Do not request the same tx from this peer delete(f.announces[peer], hash) @@ -472,6 +475,7 @@ func (f *BlobFetcher) loop() { } if completed { + blobFetcherFetchTime.Update(int64(time.Duration(f.clock.Now() - request.time))) addedHashes = append(addedHashes, hash) fetchStatus := f.fetches[hash] sort.Slice(fetchStatus.cells, func(i, j int) bool { @@ -491,6 +495,7 @@ func (f *BlobFetcher) loop() { } } // Update mempool status for arrived hashes + blobRequestDoneMeter.Mark(int64(len(delivery.txs))) if len(addedHashes) > 0 { f.addPayload(addedHashes, addedCells, delivery.cellBitmap) } @@ -585,6 +590,14 @@ func (f *BlobFetcher) loop() { case <-f.quit: return } + // Update metrics gauges + blobFetcherWaitingPeers.Update(int64(len(f.waitslots))) + blobFetcherWaitingHashes.Update(int64(len(f.waitlist))) + blobFetcherQueueingPeers.Update(int64(len(f.announces) - len(f.requests))) + blobFetcherQueueingHashes.Update(int64(len(f.announces))) + blobFetcherFetchingPeers.Update(int64(len(f.requests))) + blobFetcherFetchingHashes.Update(int64(len(f.fetches))) + // Loop did something, ping the step notifier if needed (tests) if f.step != nil { f.step <- struct{}{} @@ -722,7 +735,9 @@ func (f *BlobFetcher) scheduleFetches(timer *mclock.Timer, timeout chan struct{} f.requests[peer] = request go func(peer string, request []*cellRequest) { for _, req := range request { + blobRequestOutMeter.Mark(int64(len(req.txs))) if err := f.fetchPayloads(peer, req.txs, req.cells); err != nil { + blobRequestFailMeter.Mark(int64(len(req.txs))) f.Drop(peer) break } diff --git a/eth/fetcher/metrics.go b/eth/fetcher/metrics.go index 306690c64b..a2317e7c64 100644 --- a/eth/fetcher/metrics.go +++ b/eth/fetcher/metrics.go @@ -66,8 +66,7 @@ var ( blobRequestDoneMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/done", nil) blobRequestTimeoutMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/timeout", nil) - blobReplyInMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/replies/in", nil) - blobReplyRejectMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/replies/reject", nil) + blobReplyInMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/replies/in", nil) blobFetcherWaitingPeers = metrics.NewRegisteredGauge("eth/fetcher/blob/waiting/peers", nil) blobFetcherWaitingHashes = metrics.NewRegisteredGauge("eth/fetcher/blob/waiting/hashes", nil) From 252dd57159b3369dfdac529522e8dfd5af1604d2 Mon Sep 17 00:00:00 2001 From: healthykim Date: Fri, 20 Mar 2026 21:12:20 +0900 Subject: [PATCH 06/24] fix error from kurtosis test --- core/txpool/blobpool/blobpool.go | 156 ++++++++++++++++--------------- core/txpool/blobpool/lookup.go | 14 +-- core/txpool/subpool.go | 5 +- eth/fetcher/blob_fetcher.go | 83 ++++++++++------ eth/fetcher/blob_fetcher_test.go | 150 ++++++++++++++++++++++------- eth/fetcher/tx_fetcher.go | 3 + eth/handler.go | 4 +- eth/handler_test.go | 20 ++-- eth/protocols/eth/broadcast.go | 6 +- 9 files changed, 284 insertions(+), 157 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 8dd499250b..8aa92f851d 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -127,9 +127,10 @@ type blobTxMeta struct { 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 + 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 + sizeWithoutBlob uint64 // RLP-encoded size of transaction without blob data (for ETH/71) custody *types.CustodyBitmap @@ -152,25 +153,26 @@ type blobTxMeta struct { // newBlobTxMeta retrieves the indexed metadata fields from a pooled blob transaction // and assembles a helper struct to track in memory. func newBlobTxMeta(id uint64, size uint64, storageSize uint32, pooledTx *pooledBlobTx) *blobTxMeta { - if pooledTx.Sidecar == nil { - // This should never happen, as the pool only admits blob transactions with a sidecar - panic("missing blob tx sidecar") + var version byte + if pooledTx.Sidecar != nil { + version = pooledTx.Sidecar.Version } meta := &blobTxMeta{ - hash: pooledTx.Transaction.Hash(), - vhashes: pooledTx.Transaction.BlobHashes(), - version: pooledTx.Sidecar.Version, - id: id, - storageSize: storageSize, - size: size, - nonce: pooledTx.Transaction.Nonce(), - costCap: uint256.MustFromBig(pooledTx.Transaction.Cost()), - execTipCap: uint256.MustFromBig(pooledTx.Transaction.GasTipCap()), - execFeeCap: uint256.MustFromBig(pooledTx.Transaction.GasFeeCap()), - blobFeeCap: uint256.MustFromBig(pooledTx.Transaction.BlobGasFeeCap()), - execGas: pooledTx.Transaction.Gas(), - blobGas: pooledTx.Transaction.BlobGas(), - custody: &pooledTx.Sidecar.Custody, + hash: pooledTx.Transaction.Hash(), + vhashes: pooledTx.Transaction.BlobHashes(), + version: version, + id: id, + storageSize: storageSize, + size: size, + sizeWithoutBlob: pooledTx.SizeWithoutBlob, + nonce: pooledTx.Transaction.Nonce(), + costCap: uint256.MustFromBig(pooledTx.Transaction.Cost()), + execTipCap: uint256.MustFromBig(pooledTx.Transaction.GasTipCap()), + execFeeCap: uint256.MustFromBig(pooledTx.Transaction.GasFeeCap()), + blobFeeCap: uint256.MustFromBig(pooledTx.Transaction.BlobGasFeeCap()), + execGas: pooledTx.Transaction.Gas(), + blobGas: pooledTx.Transaction.BlobGas(), + custody: &pooledTx.Sidecar.Custody, } meta.basefeeJumps = dynamicFeeJumps(meta.execFeeCap) meta.blobfeeJumps = dynamicBlobFeeJumps(meta.blobFeeCap) @@ -179,9 +181,10 @@ func newBlobTxMeta(id uint64, size uint64, storageSize uint32, pooledTx *pooledB } type pooledBlobTx struct { - Transaction *types.Transaction - Sidecar *types.BlobTxCellSidecar - Size uint64 // original transaction size (including blobs) + Transaction *types.Transaction + Sidecar *types.BlobTxCellSidecar + Size uint64 // original transaction size (including blobs) + SizeWithoutBlob uint64 // transaction size with commitments/proofs but without blob data } // newPooledBlobTx creates pooledBlobTx struct. @@ -194,9 +197,10 @@ func newPooledBlobTx(tx *types.Transaction) (*pooledBlobTx, error) { return nil, err } return &pooledBlobTx{ - Transaction: tx.WithoutBlobTxSidecar(), - Sidecar: sidecar, - Size: tx.Size(), + Transaction: tx.WithoutBlobTxSidecar(), + Sidecar: sidecar, + Size: tx.Size(), + SizeWithoutBlob: tx.WithoutBlob().Size(), }, nil } @@ -1369,30 +1373,6 @@ func (p *BlobPool) checkDelegationLimit(tx *types.Transaction) error { return txpool.ErrInflightTxLimitReached } -// ValidateCells validates cells against transaction commitments and proofs. -func (p *BlobPool) ValidateCells(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { - errs := make([]error, len(txs)) - - for i, tx := range txs { - if _, ok := p.queue[tx]; !ok { - errs[i] = fmt.Errorf("transaction %s not found", tx) - continue - } - sidecar := p.queue[tx].BlobTxSidecar() - cellProofs := make([]kzg4844.Proof, 0) - for _, proofIdx := range custody.Indices() { - // should store all proofs - for blobIdx := range len(sidecar.Commitments) { - idx := blobIdx*kzg4844.CellProofsPerBlob + int(proofIdx) - cellProofs = append(cellProofs, sidecar.Proofs[idx]) - } - } - - errs[i] = kzg4844.VerifyCells(cells[i], sidecar.Commitments, cellProofs, custody.Indices()) - } - return errs -} - // validateTx checks whether a transaction is valid according to the consensus // rules and adheres to some heuristic limits of the local node (price and size). // This function assumes the static validation has been performed already and @@ -1441,8 +1421,7 @@ func (p *BlobPool) validateTx(tx *types.Transaction, buffer bool) error { next := p.state.GetNonce(addr) for nonce, replacement := range replacements { - if len(p.index[addr]) > int(nonce-next) { - // replacement + if nonce >= next && len(p.index[addr]) > int(nonce-next) { originalCost := p.index[addr][nonce-next].costCap replacementCost := replacement.costCap @@ -1464,8 +1443,9 @@ func (p *BlobPool) validateTx(tx *types.Transaction, buffer bool) error { if p.replacementQueue[addr] != nil && p.replacementQueue[addr][nonce] != nil { return p.replacementQueue[addr][nonce].costCap.ToBig() } - if uint64(len(p.indexQueue[addr])) > nonce-next-uint64(len(p.index[addr])) { - return p.indexQueue[addr][nonce-next-uint64(len(p.index[addr]))].costCap.ToBig() + pooledCount := uint64(len(p.index[addr])) + if nonce >= next+pooledCount && uint64(len(p.indexQueue[addr])) > nonce-next-pooledCount { + return p.indexQueue[addr][nonce-next-pooledCount].costCap.ToBig() } } if uint64(len(p.index[addr])) > nonce-next { @@ -1500,10 +1480,12 @@ func (p *BlobPool) validateTx(tx *types.Transaction, buffer bool) error { } } } else if buffer { - offset := nonce - next - uint64(len(p.index[from])) - if uint64(len(p.indexQueue[from])) > offset && offset > 0 { - // buffer tx replacement - prev = p.indexQueue[from][nonce-next-uint64(len(p.index[from]))] + pooledCount := uint64(len(p.index[from])) + if nonce >= next+pooledCount { + offset := nonce - next - pooledCount + if uint64(len(p.indexQueue[from])) > offset && offset > 0 { + prev = p.indexQueue[from][offset] + } } } if prev == nil { @@ -1557,6 +1539,13 @@ func (p *BlobPool) Has(hash common.Hash) bool { return poolHas || gapped } +func (p *BlobPool) HasPayload(hash common.Hash) bool { + p.lock.RLock() + defer p.lock.RUnlock() + + return p.lookup.exists(hash) || len(p.cellQueue[hash]) != 0 +} + // getRLP returns the raw RLP-encoded pooledBlobTx data from the store. func (p *BlobPool) getRLP(hash common.Hash) []byte { // Track the amount of time waiting to retrieve a fully resolved blob tx from @@ -1642,13 +1631,14 @@ func (p *BlobPool) GetMetadata(hash common.Hash) *txpool.TxMetadata { p.lock.RLock() defer p.lock.RUnlock() - size, ok := p.lookup.sizeOfTx(hash) + meta, ok := p.lookup.txIndex[hash] if !ok { return nil } return &txpool.TxMetadata{ - Type: types.BlobTxType, - Size: size, + Type: types.BlobTxType, + Size: meta.size, + SizeWithoutBlob: meta.sizeWithoutBlob, } } @@ -1771,8 +1761,8 @@ func (p *BlobPool) Add(txs []*types.Transaction, sync bool) []error { if errs[i] = p.ValidateTxBasics(tx); errs[i] != nil { continue } - if len(tx.BlobTxSidecar().Blobs) != 0 { - // from user: convert to pooledBlobTx and add + sc := tx.BlobTxSidecar() + if sc != nil && len(sc.Blobs) != 0 { pooledTx, err := newPooledBlobTx(tx) if err != nil { errs[i] = err @@ -1780,7 +1770,6 @@ func (p *BlobPool) Add(txs []*types.Transaction, sync bool) []error { } errs[i] = p.add(pooledTx) } else { - // from p2p, buffer until the corresponding cells arrive errs[i] = p.addBuffer(tx) } } @@ -1795,7 +1784,8 @@ func (p *BlobPool) addBuffer(tx *types.Transaction) (err error) { sidecar := tx.BlobTxSidecar() var cellSidecar types.BlobTxCellSidecar - if len(cells) >= kzg4844.DataPerBlob { + blobCount := len(sidecar.Commitments) + if len(cells) >= kzg4844.DataPerBlob*blobCount { blob, err := kzg4844.RecoverBlobs(cells, p.custodyQueue[tx.Hash()].Indices()) if err != nil { return err @@ -1832,13 +1822,25 @@ func (p *BlobPool) addBuffer(tx *types.Transaction) (err error) { if err := p.validateTx(tx, true); err != nil { return err } + // Store the original tx in queue (with BlobTxSidecar intact — Blobs may be nil + // from ETH/71 but commitments/proofs are preserved for cell validation later). p.queue[tx.Hash()] = tx from, _ := types.Sender(p.signer, tx) + // Build a partial pooledBlobTx for metadata tracking. + var cellSidecar *types.BlobTxCellSidecar + if sidecar := tx.BlobTxSidecar(); sidecar != nil { + cellSidecar = &types.BlobTxCellSidecar{ + Version: sidecar.Version, + Commitments: sidecar.Commitments, + Proofs: sidecar.Proofs, + } + } next := p.state.GetNonce(from) nonce := tx.Nonce() pooledCount := uint64(len(p.index[from])) - meta := newBlobTxMeta(0, tx.Size(), 0, &pooledBlobTx{Transaction: tx, Size: tx.Size()}) + //todo this is strange + meta := newBlobTxMeta(0, tx.Size(), 0, &pooledBlobTx{Transaction: tx, Sidecar: cellSidecar, Size: tx.Size()}) if nonce < next+pooledCount { // Pooled transaction replacements are stored in replacementQueue for expenditure validation @@ -1944,7 +1946,6 @@ func (p *BlobPool) addLocked(pooledTx *pooledBlobTx, checkGapped bool) (err erro Config: p.chain.Config(), MaxBlobCount: maxBlobsPerTx, }); err != nil { - log.Trace("Sidecar validation failed", "hash", tx.Hash(), "err", err) return err } // If the address is not yet known, request exclusivity to track the account @@ -2551,12 +2552,13 @@ func (p *BlobPool) GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg48 } tx := pooledTx.Transaction sidecar := pooledTx.Sidecar + // Return cells in blob-major order: [blob0_cell0, blob0_cell1, ..., blob1_cell0, ...] + cellsPerBlob := sidecar.Custody.OneCount() cells := make([]kzg4844.Cell, 0, mask.OneCount()*len(tx.BlobHashes())) - for cellIdx, custodyIdx := range sidecar.Custody.Indices() { - if mask.IsSet(custodyIdx) { - for blobIdx := 0; blobIdx < len(tx.BlobHashes()); blobIdx++ { - idx := blobIdx*sidecar.Custody.OneCount() + cellIdx - cells = append(cells, sidecar.Cells[idx]) + for blobIdx := 0; blobIdx < len(tx.BlobHashes()); blobIdx++ { + for cellIdx, custodyIdx := range sidecar.Custody.Indices() { + if mask.IsSet(custodyIdx) { + cells = append(cells, sidecar.Cells[blobIdx*cellsPerBlob+cellIdx]) } } } @@ -2581,7 +2583,8 @@ func (p *BlobPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custody sidecar := p.queue[hash].BlobTxSidecar() var cellSidecar types.BlobTxCellSidecar - if len(cells[i]) >= kzg4844.DataPerBlob { + blobCount := len(sidecar.Commitments) + if len(cells[i]) >= kzg4844.DataPerBlob*blobCount { blob, err := kzg4844.RecoverBlobs(cells[i], custody.Indices()) if err != nil { errs[i] = err @@ -2610,7 +2613,6 @@ func (p *BlobPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custody } errs[i] = p.addLocked(&pooledBlobTx{Transaction: p.queue[hash].WithoutBlobTxSidecar(), Sidecar: &cellSidecar, Size: p.queue[hash].Size()}, true) - // todo nonce gap // clean up queues tx := p.queue[hash] @@ -2628,7 +2630,11 @@ func (p *BlobPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custody } // plain tx - offset := int(nonce - next - uint64(len(p.index[from]))) + pooledCount := uint64(len(p.index[from])) + if nonce < next+pooledCount { + continue + } + offset := int(nonce - next - pooledCount) if offset > 0 && offset < len(p.indexQueue[from]) { removed := p.indexQueue[from][offset] p.indexQueue[from] = append(p.indexQueue[from][:offset], p.indexQueue[from][offset+1:]...) diff --git a/core/txpool/blobpool/lookup.go b/core/txpool/blobpool/lookup.go index e105d47706..39cb2c69b9 100644 --- a/core/txpool/blobpool/lookup.go +++ b/core/txpool/blobpool/lookup.go @@ -22,9 +22,10 @@ import ( ) type txMetadata struct { - id uint64 // the billy id of transction - size uint64 // the RLP encoded size of transaction (blobs are included) - custody types.CustodyBitmap + id uint64 // the billy id of transction + size uint64 // the RLP encoded size of transaction (blobs are included) + sizeWithoutBlob uint64 // the RLP encoded size without blob data (for ETH/71 announcements) + custody types.CustodyBitmap } // lookup maps blob versioned hashes to transaction hashes that include them, @@ -93,9 +94,10 @@ func (l *lookup) track(tx *blobTxMeta) { } // Map the transaction hash to the datastore id and RLP-encoded transaction size l.txIndex[tx.hash] = &txMetadata{ - id: tx.id, - size: tx.size, - custody: *tx.custody, + id: tx.id, + size: tx.size, + sizeWithoutBlob: tx.sizeWithoutBlob, + custody: *tx.custody, } } diff --git a/core/txpool/subpool.go b/core/txpool/subpool.go index dfd0ccead7..59d60cb651 100644 --- a/core/txpool/subpool.go +++ b/core/txpool/subpool.go @@ -86,8 +86,9 @@ type PendingFilter struct { // TxMetadata denotes the metadata of a transaction. type TxMetadata struct { - Type uint8 // The type of the transaction - Size uint64 // The length of the 'rlp encoding' of a transaction + Type uint8 // The type of the transaction + Size uint64 // The length of the 'rlp encoding' of a transaction (including blobs) + SizeWithoutBlob uint64 // The length without blob data (for ETH/71 announcements) } // SubPool represents a specialized transaction pool that lives on its own (e.g. diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index 097bc5aa66..98d0ff24c4 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -40,15 +40,16 @@ type random interface { // according to the custody cell indices provided by the consensus client // connected to this execution client. +// todo var blobFetchTimeout = 5 * time.Second +var blobAvailabilityTimeout = 2 * time.Second -// todo tuning const ( availabilityThreshold = 2 maxPayloadRetrievals = 128 maxPayloadAnnounces = 4096 + fetchProbability = 15 MAX_CELLS_PER_PARTIAL_REQUEST = 8 - blobAvailabilityTimeout = 500 * time.Millisecond ) type blobTxAnnounce struct { @@ -76,9 +77,10 @@ type cellWithSeq struct { } type fetchStatus struct { - fetching *types.CustodyBitmap // To avoid fetching cells which had already been fetched / currently being fetched - fetched []uint64 // To sort cells - cells []kzg4844.Cell + fetching *types.CustodyBitmap // To avoid fetching cells which had already been fetched / currently being fetched + fetched []uint64 // Custody indices that have been fetched (per-blob, same for all blobs) + blobCells [][]kzg4844.Cell // Per-blob cell accumulator, indexed by blob + blobCount int // Number of blobs in this tx (set on first delivery) } // BlobFetcher is responsible for managing type 3 transactions based on peer announcements. @@ -121,8 +123,8 @@ type BlobFetcher struct { alternates map[common.Hash]map[string]*types.CustodyBitmap // In-flight transaction alternate origins (in case the peer is dropped) // Callbacks - validateCells func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error - addPayload func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error + hasPayload func(common.Hash) bool + addPayload func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error //todo: peer disconnection is strange here fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error dropPeer func(string) @@ -133,7 +135,7 @@ type BlobFetcher struct { } func NewBlobFetcher( - validateCells func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error, + hasPayload func(common.Hash) bool, addPayload func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error, fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error, dropPeer func(string), custody *types.CustodyBitmap, rand random) *BlobFetcher { @@ -151,7 +153,7 @@ func NewBlobFetcher( fetches: make(map[common.Hash]*fetchStatus), requests: make(map[string][]*cellRequest), alternates: make(map[common.Hash]map[string]*types.CustodyBitmap), - validateCells: validateCells, + hasPayload: hasPayload, addPayload: addPayload, fetchPayloads: fetchPayloads, dropPeer: dropPeer, @@ -165,7 +167,15 @@ func NewBlobFetcher( // Notify is called when a Type 3 transaction is observed on the network. (TransactionPacket / NewPooledTransactionHashesPacket) func (f *BlobFetcher) Notify(peer string, txs []common.Hash, cells types.CustodyBitmap) error { blobAnnounceInMeter.Mark(int64(len(txs))) - blobAnnounce := &blobTxAnnounce{origin: peer, txs: txs, cells: cells} + anns := make([]common.Hash, 0) + for _, tx := range txs { + if f.hasPayload(tx) { + continue + } + anns = append(anns, tx) + } + + blobAnnounce := &blobTxAnnounce{origin: peer, txs: anns, cells: cells} select { case f.notify <- blobAnnounce: return nil @@ -261,7 +271,7 @@ func (f *BlobFetcher) loop() { } else { randomValue = f.rand.Intn(100) } - if randomValue < 15 { + if randomValue < fetchProbability { f.full[hash] = struct{}{} } else { f.partial[hash] = struct{}{} @@ -418,9 +428,6 @@ func (f *BlobFetcher) loop() { f.rescheduleTimeout(timeoutTimer, timeoutTrigger) case delivery := <-f.cleanup: // Remove from announce - addedHashes := make([]common.Hash, 0) - addedCells := make([][]kzg4844.Cell, 0) - var requestId int var request *cellRequest for _, hash := range delivery.txs { @@ -446,9 +453,24 @@ func (f *BlobFetcher) loop() { // Unexpected hash, ignore continue } - // Update fetch status - f.fetches[hash].fetched = append(f.fetches[hash].fetched, delivery.cellBitmap.Indices()...) - f.fetches[hash].cells = append(f.fetches[hash].cells, delivery.cells[i]...) + // delivery.cells[i] contains cells for all blobs + // in blob-major order: [blob0_cell0, ..., blob0_cellN, blob1_cell0, ...]. + indices := delivery.cellBitmap.Indices() + cellsPerBlob := len(indices) + if cellsPerBlob > 0 { + status := f.fetches[hash] + blobCount := len(delivery.cells[i]) / cellsPerBlob + // Initialize per-blob accumulators on first delivery + if status.blobCount == 0 { + status.blobCount = blobCount + status.blobCells = make([][]kzg4844.Cell, blobCount) + } + for b := 0; b < blobCount; b++ { + offset := b * cellsPerBlob + status.blobCells[b] = append(status.blobCells[b], delivery.cells[i][offset:offset+cellsPerBlob]...) + } + status.fetched = append(status.fetched, indices...) + } // Update announces of this peer delete(f.announces[delivery.origin], hash) @@ -476,12 +498,26 @@ func (f *BlobFetcher) loop() { if completed { blobFetcherFetchTime.Update(int64(time.Duration(f.clock.Now() - request.time))) - addedHashes = append(addedHashes, hash) fetchStatus := f.fetches[hash] - sort.Slice(fetchStatus.cells, func(i, j int) bool { - return fetchStatus.fetched[i] < fetchStatus.fetched[j] + + // Sort each blob's cells by ascending custody index. + // RecoverBlobs expects cells[k] to correspond to custodyIndices[k], + // and custodyIndices come from CustodyBitmap.Indices() which is always sorted. + perm := make([]int, len(fetchStatus.fetched)) + for i := range perm { + perm[i] = i + } + slices.SortFunc(perm, func(a, b int) int { + return int(fetchStatus.fetched[a]) - int(fetchStatus.fetched[b]) }) - addedCells = append(addedCells, fetchStatus.cells) + var assembled []kzg4844.Cell + for _, blobCells := range fetchStatus.blobCells { + for _, p := range perm { + assembled = append(assembled, blobCells[p]) + } + } + collectedCustody := types.NewCustodyBitmap(fetchStatus.fetched) + f.addPayload([]common.Hash{hash}, [][]kzg4844.Cell{assembled}, &collectedCustody) // remove announces from other peers for peer, txset := range f.announces { @@ -494,11 +530,7 @@ func (f *BlobFetcher) loop() { delete(f.fetches, hash) } } - // Update mempool status for arrived hashes blobRequestDoneMeter.Mark(int64(len(delivery.txs))) - if len(addedHashes) > 0 { - f.addPayload(addedHashes, addedCells, delivery.cellBitmap) - } // Remove the request f.requests[delivery.origin][requestId] = f.requests[delivery.origin][len(f.requests[delivery.origin])-1] @@ -690,7 +722,6 @@ func (f *BlobFetcher) scheduleFetches(timer *mclock.Timer, timeout chan struct{} f.fetches[hash] = &fetchStatus{ fetching: unfetched, fetched: make([]uint64, 0), - cells: make([]kzg4844.Cell, 0), } } else { f.fetches[hash].fetching = f.fetches[hash].fetching.Union(unfetched) diff --git a/eth/fetcher/blob_fetcher_test.go b/eth/fetcher/blob_fetcher_test.go index 02b42eda3b..c6e4400772 100644 --- a/eth/fetcher/blob_fetcher_test.go +++ b/eth/fetcher/blob_fetcher_test.go @@ -17,9 +17,9 @@ package fetcher import ( + "fmt" "slices" "testing" - "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/mclock" @@ -64,11 +64,6 @@ func selectCells(cells []kzg4844.Cell, custody *types.CustodyBitmap) []kzg4844.C return result } -const ( - testBlobAvailabilityTimeout = 500 * time.Millisecond - testBlobFetchTimeout = 5 * time.Second -) - var ( testBlobTxHashes = []common.Hash{ {0x01}, {0x02}, {0x03}, {0x04}, {0x05}, {0x06}, {0x07}, {0x08}, @@ -153,16 +148,14 @@ func TestBlobFetcherFullFetch(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) - }, + func(common.Hash) bool { return false }, func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { return make([]error, len(txs)) }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, &custody, - &mockRand{value: 5}, // to force full requests (5 < 15) + &mockRand{value: 5}, // Force full requests (5 < fetchProbability) ) }, steps: []interface{}{ @@ -244,16 +237,14 @@ func TestBlobFetcherPartialFetch(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) - }, + func(common.Hash) bool { return false }, func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { return make([]error, len(txs)) }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, &custody, - &mockRand{value: 20}, // Force partial requests (20 >= 15) + &mockRand{value: 60}, // Force partial requests (20 >= 15) ) }, steps: []interface{}{ @@ -339,9 +330,7 @@ func TestBlobFetcherFullDelivery(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) - }, + func(common.Hash) bool { return false }, func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { return make([]error, len(txs)) }, @@ -387,16 +376,14 @@ func TestBlobFetcherPartialDelivery(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) - }, + func(common.Hash) bool { return false }, func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { return make([]error, len(txs)) }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, &custody, - &mockRand{value: 20}, + &mockRand{value: 60}, ) }, steps: []interface{}{ @@ -523,16 +510,14 @@ func TestBlobFetcherAvailabilityTimeout(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) - }, + func(common.Hash) bool { return false }, func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { return make([]error, len(txs)) }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, &custody, - &mockRand{value: 20}, + &mockRand{value: 60}, ) }, steps: []interface{}{ @@ -543,7 +528,7 @@ func TestBlobFetcherAvailabilityTimeout(t *testing.T) { isBlobScheduled{announces: nil, fetching: nil}, // Run clock for timeout - doWait{time: testBlobAvailabilityTimeout, step: true}, + doWait{time: blobAvailabilityTimeout, step: true}, // After timeout, waitlist should be empty isWaitingAvailability{}, @@ -557,9 +542,7 @@ func TestBlobFetcherPeerDrop(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) - }, + func(common.Hash) bool { return false }, func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { return make([]error, len(txs)) }, @@ -634,9 +617,7 @@ func TestBlobFetcherFetchTimeout(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) - }, + func(common.Hash) bool { return false }, func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { return make([]error, len(txs)) }, @@ -680,7 +661,7 @@ func TestBlobFetcherFetchTimeout(t *testing.T) { }, // Wait for fetch timeout -> should reschedule to peer B - doWait{time: testBlobFetchTimeout, step: true}, + doWait{time: blobFetchTimeout, step: true}, isBlobScheduled{ announces: map[string][]blobAnnounce{ "B": {{hash: testBlobTxHashes[0], custody: halfCustody}}, @@ -699,7 +680,7 @@ func TestBlobFetcherFetchTimeout(t *testing.T) { }, // Wait for timeout -> should drop transaction - doWait{time: testBlobFetchTimeout, step: true}, + doWait{time: blobFetchTimeout, step: true}, isBlobScheduled{announces: nil, fetching: nil}, isFetching{hashes: nil}, }, @@ -997,3 +978,104 @@ func testBlobFetcher(t *testing.T, tt blobFetcherTest) { } } } + +// selectMultiBlobCells extracts cells from a multi-blob sidecar for a given +// custody mask, returning them in blob-major order. +func selectMultiBlobCells(sc *types.BlobTxCellSidecar, mask types.CustodyBitmap) []kzg4844.Cell { + var result []kzg4844.Cell + cellsPerBlob := sc.Custody.OneCount() + blobCount := len(sc.Cells) / cellsPerBlob + for b := 0; b < blobCount; b++ { + for _, idx := range mask.Indices() { + result = append(result, sc.Cells[b*cellsPerBlob+int(idx)]) + } + } + return result +} + +// TestMultiBlobDeliveryVerification tests that cells delivered in two partial +// deliveries for a multi-blob tx are correctly assembled and pass KZG cell +// proof verification via the addPayload callback. +func TestMultiBlobDeliveryVerification(t *testing.T) { + sidecar := testBlobSidecars[2] // 3 blobs + + var verifyErr error + testBlobFetcher(t, blobFetcherTest{ + init: func() *BlobFetcher { + return NewBlobFetcher( + func(common.Hash) bool { return false }, + func(txs []common.Hash, cells [][]kzg4844.Cell, cst *types.CustodyBitmap) []error { + // Verify delivered cells pass KZG cell proof verification + // Debug: compare with expected cells + expectedCells := selectMultiBlobCells(sidecar, custody) + for ci, c := range cells { + if len(c) != len(expectedCells) { + verifyErr = fmt.Errorf("cell count mismatch: have %d, want %d", len(c), len(expectedCells)) + return make([]error, len(txs)) + } + for j := range c { + if c[j] != expectedCells[j] { + verifyErr = fmt.Errorf("tx %d cell %d mismatch (custody=%v)", ci, j, cst.Indices()) + return make([]error, len(txs)) + } + } + } + for _, c := range cells { + cs := &types.BlobTxCellSidecar{ + Version: sidecar.Version, + Cells: c, + Commitments: sidecar.Commitments, + Proofs: sidecar.Proofs, + Custody: *cst, + } + indices := cs.Custody.Indices() + var cellProofs []kzg4844.Proof + for blobIdx := range len(cs.Commitments) { + for _, proofIdx := range indices { + cellProofs = append(cellProofs, cs.Proofs[blobIdx*kzg4844.CellProofsPerBlob+int(proofIdx)]) + } + } + verifyErr = kzg4844.VerifyCells(cs.Cells, cs.Commitments, cellProofs, indices) + } + return make([]error, len(txs)) + }, + func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, + func(string) {}, + &custody, + &mockRand{value: 60}, // Force partial requests (60 >= fetchProbability) + ) + }, + steps: []interface{}{ + // Two full-custody peers → passes availability, promotes to announces + doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody}, + + // Two partial peers with front/back custody + doBlobNotify{peer: "D", hashes: []common.Hash{testBlobTxHashes[0]}, custody: backCustody}, + doBlobNotify{peer: "C", hashes: []common.Hash{testBlobTxHashes[0]}, custody: frontCustody}, + + // Drop A and B so C and D get scheduled for fetch + doDrop("A"), + doDrop("B"), + + // Deliver back cells from D → completes fetch and triggers addPayload + doBlobEnqueue{ + peer: "D", + hashes: []common.Hash{testBlobTxHashes[0]}, + cells: [][]kzg4844.Cell{selectMultiBlobCells(sidecar, *backCustody.Intersection(&custody))}, + custody: *backCustody.Intersection(&custody), + }, + // Deliver front cells from C + doBlobEnqueue{ + peer: "C", + hashes: []common.Hash{testBlobTxHashes[0]}, + cells: [][]kzg4844.Cell{selectMultiBlobCells(sidecar, *frontCustody.Intersection(&custody))}, + custody: *frontCustody.Intersection(&custody), + }, + isCompleted{testBlobTxHashes[0]}, + }, + }) + if verifyErr != nil { + t.Fatalf("KZG cell verification failed after multi-blob delivery: %v", verifyErr) + } +} diff --git a/eth/fetcher/tx_fetcher.go b/eth/fetcher/tx_fetcher.go index 271c9ddec2..f4e9271302 100644 --- a/eth/fetcher/tx_fetcher.go +++ b/eth/fetcher/tx_fetcher.go @@ -254,6 +254,9 @@ func (f *TxFetcher) Notify(peer string, kinds []byte, sizes []uint32, hashes []c for i, hash := range hashes { err := f.validateMeta(hash, kinds[i]) if errors.Is(err, txpool.ErrAlreadyKnown) { + if kinds[i] == types.BlobTxType { + blobFetchHashes = append(blobFetchHashes, hash) + } duplicate++ continue } diff --git a/eth/handler.go b/eth/handler.go index 4dbb764d92..4ece05d013 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -103,7 +103,7 @@ type txPool interface { type blobPool interface { Has(hash common.Hash) bool GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) - ValidateCells([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error + HasPayload(hash common.Hash) bool AddPayload([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error GetCustody(hash common.Hash) *types.CustodyBitmap } @@ -214,7 +214,7 @@ func newHandler(config *handlerConfig) (*handler, error) { } return p.RequestPayload(hashes, cells) } - h.blobFetcher = fetcher.NewBlobFetcher(h.blobpool.ValidateCells, h.blobpool.AddPayload, fetchPayloads, h.removePeer, &config.Custody, nil) + h.blobFetcher = fetcher.NewBlobFetcher(h.blobpool.HasPayload, h.blobpool.AddPayload, fetchPayloads, h.removePeer, &config.Custody, nil) return h, nil } diff --git a/eth/handler_test.go b/eth/handler_test.go index 8839e15019..384acffb61 100644 --- a/eth/handler_test.go +++ b/eth/handler_test.go @@ -83,6 +83,15 @@ func (p *testTxPool) Has(hash common.Hash) bool { return p.txPool[hash] != nil } +// Has returns an indicator whether txpool has a transaction +// cached with the given hash. +func (p *testTxPool) HasPayload(hash common.Hash) bool { + p.lock.Lock() + defer p.lock.Unlock() + + return p.cellPool[hash] != nil +} + // Get retrieves the transaction from local txpool with given // tx hash. func (p *testTxPool) Get(hash common.Hash) *types.Transaction { @@ -223,17 +232,6 @@ func (p *testTxPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custo return nil } -func (p *testTxPool) ValidateCells(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { - p.lock.Lock() - defer p.lock.Unlock() - - errors := make([]error, len(txs)) - for i, tx := range txs { - errors[i] = kzg4844.VerifyCells(cells[i], p.txPool[tx].BlobTxSidecar().Commitments, p.txPool[tx].BlobTxSidecar().Proofs, custody.Indices()) - } - return errors -} - // FilterType should check whether the pool supports the given type of transactions. func (p *testTxPool) FilterType(kind byte) bool { switch kind { diff --git a/eth/protocols/eth/broadcast.go b/eth/protocols/eth/broadcast.go index 8944b0d7f7..0ecee5d2ba 100644 --- a/eth/protocols/eth/broadcast.go +++ b/eth/protocols/eth/broadcast.go @@ -133,7 +133,11 @@ func (p *Peer) announceTransactions() { } pending = append(pending, queue[count]) pendingTypes = append(pendingTypes, meta.Type) - pendingSizes = append(pendingSizes, uint32(meta.Size)) + if p.version >= ETH71 && meta.SizeWithoutBlob > 0 { + pendingSizes = append(pendingSizes, uint32(meta.SizeWithoutBlob)) + } else { + pendingSizes = append(pendingSizes, uint32(meta.Size)) + } size += common.HashLength processed[count] = true From 1f5eec01b05078eec62be5c8aa3c9421233038e0 Mon Sep 17 00:00:00 2001 From: healthykim Date: Tue, 24 Mar 2026 20:55:24 +0900 Subject: [PATCH 07/24] add hasPayload flag for Get function --- core/txpool/blobpool/blobpool.go | 14 +++++++++++++- core/txpool/blobpool/blobpool_test.go | 14 +++++++------- core/txpool/legacypool/legacypool.go | 2 +- core/txpool/subpool.go | 6 +++--- core/txpool/txpool.go | 4 ++-- eth/api_backend.go | 2 +- eth/handler.go | 2 +- eth/handler_test.go | 2 +- eth/protocols/eth/broadcast.go | 2 +- eth/protocols/eth/handler.go | 2 +- 10 files changed, 31 insertions(+), 19 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 8aa92f851d..7328eba460 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1573,7 +1573,16 @@ func (p *BlobPool) getRLP(hash common.Hash) []byte { } // Get returns a transaction if it is contained in the pool, or nil otherwise. -func (p *BlobPool) Get(hash common.Hash) *types.Transaction { +// TODO: We could do the following (especially beneficial for GetRLP): +// 1) Make Get and GetRLP return blob transactions without blobs (not without sidecars). +// 2) Store transactions (without blobs) and cells separately: +// - (1) Store them separately on disk, tracking both IDs. +// - (2) Keep transactions in memory and store cells on disk. +// +// However, this approach does not fit well with eth71 peers, since blobs +// must be included in that case. It may require decoding and re-encoding, +// as well as double disk I/O each time. +func (p *BlobPool) Get(hash common.Hash, includeBlob bool) *types.Transaction { data := p.getRLP(hash) if len(data) == 0 { return nil @@ -1583,6 +1592,9 @@ func (p *BlobPool) Get(hash common.Hash) *types.Transaction { log.Error("Blobs corrupted for traced transaction", "hash", hash, "err", err) return nil } + if !includeBlob { + return pooledTx.Transaction + } tx, err := pooledTx.convert() if err != nil { log.Error("Failed to convert transaction in blobpool", "hash", hash, "err", err) diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index 41d1bbe383..98abd96bc4 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -1158,10 +1158,10 @@ func TestChangingSlotterSize(t *testing.T) { } // Verify the regular two txs are always available. - if got := pool.Get(tx1.Hash()); got == nil { + if got := pool.Get(tx1.Hash(), true); got == nil { t.Errorf("expected tx %s from %s in pool", tx1.Hash(), addr1) } - if got := pool.Get(tx2.Hash()); got == nil { + if got := pool.Get(tx2.Hash(), true); got == nil { t.Errorf("expected tx %s from %s in pool", tx2.Hash(), addr2) } @@ -1267,10 +1267,10 @@ func TestBillyMigration(t *testing.T) { } // Verify the regular two txs are always available. - if got := pool.Get(tx1.Hash()); got == nil { + if got := pool.Get(tx1.Hash(), true); got == nil { t.Errorf("expected tx %s from %s in pool", tx1.Hash(), addr1) } - if got := pool.Get(tx2.Hash()); got == nil { + if got := pool.Get(tx2.Hash(), true); got == nil { t.Errorf("expected tx %s from %s in pool", tx2.Hash(), addr2) } @@ -1905,13 +1905,13 @@ func TestGetBlobs(t *testing.T) { } // Verify the regular three txs are always available. - if got := pool.Get(tx1.Hash()); got == nil { + if got := pool.Get(tx1.Hash(), true); got == nil { t.Errorf("expected tx %s from %s in pool", tx1.Hash(), addr1) } - if got := pool.Get(tx2.Hash()); got == nil { + if got := pool.Get(tx2.Hash(), true); got == nil { t.Errorf("expected tx %s from %s in pool", tx2.Hash(), addr2) } - if got := pool.Get(tx3.Hash()); got == nil { + if got := pool.Get(tx3.Hash(), true); got == nil { t.Errorf("expected tx %s from %s in pool", tx3.Hash(), addr3) } diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go index dd6c539ec6..7b31dad202 100644 --- a/core/txpool/legacypool/legacypool.go +++ b/core/txpool/legacypool/legacypool.go @@ -997,7 +997,7 @@ func (pool *LegacyPool) Status(hash common.Hash) txpool.TxStatus { } // Get returns a transaction if it is contained in the pool and nil otherwise. -func (pool *LegacyPool) Get(hash common.Hash) *types.Transaction { +func (pool *LegacyPool) Get(hash common.Hash, _ bool) *types.Transaction { tx := pool.get(hash) if tx == nil { return nil diff --git a/core/txpool/subpool.go b/core/txpool/subpool.go index 59d60cb651..5f35f0a44a 100644 --- a/core/txpool/subpool.go +++ b/core/txpool/subpool.go @@ -55,7 +55,7 @@ func (ltx *LazyTransaction) Resolve() *types.Transaction { if ltx.Tx != nil { return ltx.Tx } - return ltx.Pool.Get(ltx.Hash) + return ltx.Pool.Get(ltx.Hash, true) } // LazyResolver is a minimal interface needed for a transaction pool to satisfy @@ -63,7 +63,7 @@ func (ltx *LazyTransaction) Resolve() *types.Transaction { // pool being injected into the lazy transaction. type LazyResolver interface { // Get returns a transaction if it is contained in the pool, or nil otherwise. - Get(hash common.Hash) *types.Transaction + Get(hash common.Hash, includeBlob bool) *types.Transaction } // PendingFilter is a collection of filter rules to allow retrieving a subset @@ -130,7 +130,7 @@ type SubPool interface { Has(hash common.Hash) bool // Get returns a transaction if it is contained in the pool, or nil otherwise. - Get(hash common.Hash) *types.Transaction + Get(hash common.Hash, includeBlob bool) *types.Transaction // GetRLP returns a RLP-encoded transaction if it is contained in the pool. // If includeBlob is false, blob data is stripped from blob transactions (ETH/71). diff --git a/core/txpool/txpool.go b/core/txpool/txpool.go index a9075cfd91..5e42dfa0d0 100644 --- a/core/txpool/txpool.go +++ b/core/txpool/txpool.go @@ -274,9 +274,9 @@ func (p *TxPool) Has(hash common.Hash) bool { } // Get returns a transaction if it is contained in the pool, or nil otherwise. -func (p *TxPool) Get(hash common.Hash) *types.Transaction { +func (p *TxPool) Get(hash common.Hash, includeBlob bool) *types.Transaction { for _, subpool := range p.subpools { - if tx := subpool.Get(hash); tx != nil { + if tx := subpool.Get(hash, includeBlob); tx != nil { return tx } } diff --git a/eth/api_backend.go b/eth/api_backend.go index 726d8316a0..1630fc9f6c 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -360,7 +360,7 @@ func (b *EthAPIBackend) GetPoolTransactions() (types.Transactions, error) { } func (b *EthAPIBackend) GetPoolTransaction(hash common.Hash) *types.Transaction { - return b.eth.txPool.Get(hash) + return b.eth.txPool.Get(hash, true) } // GetCanonicalTransaction retrieves the lookup along with the transaction itself diff --git a/eth/handler.go b/eth/handler.go index 4ece05d013..c584e7a78b 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -72,7 +72,7 @@ type txPool interface { // Get retrieves the transaction from local txpool with given // tx hash. - Get(hash common.Hash) *types.Transaction + Get(hash common.Hash, includeBlob bool) *types.Transaction // GetRLP retrieves the RLP-encoded transaction from local txpool // with given tx hash. diff --git a/eth/handler_test.go b/eth/handler_test.go index 384acffb61..0bb994cb13 100644 --- a/eth/handler_test.go +++ b/eth/handler_test.go @@ -94,7 +94,7 @@ func (p *testTxPool) HasPayload(hash common.Hash) bool { // Get retrieves the transaction from local txpool with given // tx hash. -func (p *testTxPool) Get(hash common.Hash) *types.Transaction { +func (p *testTxPool) Get(hash common.Hash, includeBlob bool) *types.Transaction { p.lock.Lock() defer p.lock.Unlock() return p.txPool[hash] diff --git a/eth/protocols/eth/broadcast.go b/eth/protocols/eth/broadcast.go index 0ecee5d2ba..a1fa419f40 100644 --- a/eth/protocols/eth/broadcast.go +++ b/eth/protocols/eth/broadcast.go @@ -47,7 +47,7 @@ func (p *Peer) broadcastTransactions() { size common.StorageSize ) for i := 0; i < len(queue) && size < maxTxPacketSize; i++ { - if tx := p.txpool.Get(queue[i]); tx != nil { + if tx := p.txpool.Get(queue[i], false); tx != nil { txs = append(txs, tx) size += common.StorageSize(tx.Size()) } diff --git a/eth/protocols/eth/handler.go b/eth/protocols/eth/handler.go index f5f4cfb34c..41a58abc08 100644 --- a/eth/protocols/eth/handler.go +++ b/eth/protocols/eth/handler.go @@ -100,7 +100,7 @@ type BlobPool interface { // TxPool defines the methods needed by the protocol handler to serve transactions. type TxPool interface { // Get retrieves the transaction from the local txpool with the given hash. - Get(hash common.Hash) *types.Transaction + Get(hash common.Hash, includeBlob bool) *types.Transaction // GetRLP retrieves the RLP-encoded transaction from the local txpool with // the given hash. From 226fbf6d44ae83c834cee1a56e4f5c94387fb3c5 Mon Sep 17 00:00:00 2001 From: healthykim Date: Tue, 24 Mar 2026 20:55:44 +0900 Subject: [PATCH 08/24] add availability timeout metric --- eth/fetcher/blob_fetcher.go | 1 + eth/fetcher/metrics.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index 98d0ff24c4..d8a2f205ea 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -378,6 +378,7 @@ func (f *BlobFetcher) loop() { } delete(f.waittime, hash) delete(f.waitlist, hash) + blobAnnounceTimeoutMeter.Mark(1) } } diff --git a/eth/fetcher/metrics.go b/eth/fetcher/metrics.go index a2317e7c64..4eb4db0a99 100644 --- a/eth/fetcher/metrics.go +++ b/eth/fetcher/metrics.go @@ -60,6 +60,8 @@ var ( blobAnnounceInMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/in", nil) blobAnnounceDOSMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/dos", nil) + // This metric is to track the number of availability failure + blobAnnounceTimeoutMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/timeout", nil) blobRequestOutMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/out", nil) blobRequestFailMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/fail", nil) From ff731ce79c27e090ad884f3ded9d476828dbae3e Mon Sep 17 00:00:00 2001 From: healthykim Date: Wed, 25 Mar 2026 15:07:29 +0900 Subject: [PATCH 09/24] add fallback mechanism for availability failure --- eth/fetcher/blob_fetcher.go | 28 ++++++++++++++++++++++------ eth/fetcher/blob_fetcher_test.go | 14 +++++++++++--- eth/fetcher/metrics.go | 2 +- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index d8a2f205ea..f928a99517 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -364,24 +364,40 @@ func (f *BlobFetcher) loop() { } case <-waitTrigger: - // At least one transaction's waiting time ran out, pop all expired ones - // and update the blobpool according to availability + // At least one transaction's waiting time ran out. Instead of dropping, + // convert timed-out partial fetches to full fetches so we don't lose + // the transaction. All peers in the waitlist announced full custody + // (that was the entry condition), so they can serve as full fetch sources. + reschedule := make(map[string]struct{}) for hash, instance := range f.waittime { if time.Duration(f.clock.Now()-instance)+txGatherSlack > blobAvailabilityTimeout { - // No need to check availability count (transactions that passed - // the threshold are already promoted to the announces map on notification) + // partial -> full conversion + delete(f.partial, hash) + f.full[hash] = struct{}{} + blobAnnounceTimeoutMeter.Mark(1) + for peer := range f.waitlist[hash] { + if f.announces[peer] == nil { + f.announces[peer] = make(map[common.Hash]*cellWithSeq) + } + f.announces[peer][hash] = &cellWithSeq{ + cells: types.CustodyBitmapData, + seq: f.txSeq, + } + f.txSeq++ delete(f.waitslots[peer], hash) if len(f.waitslots[peer]) == 0 { delete(f.waitslots, peer) } + reschedule[peer] = struct{}{} } delete(f.waittime, hash) delete(f.waitlist, hash) - blobAnnounceTimeoutMeter.Mark(1) } } - + if len(reschedule) > 0 { + f.scheduleFetches(timeoutTimer, timeoutTrigger, reschedule) + } // If transactions are still waiting for availability, reschedule the wait timer if len(f.waittime) > 0 { f.rescheduleWait(waitTimer, waitTrigger) diff --git a/eth/fetcher/blob_fetcher_test.go b/eth/fetcher/blob_fetcher_test.go index c6e4400772..de11b30da3 100644 --- a/eth/fetcher/blob_fetcher_test.go +++ b/eth/fetcher/blob_fetcher_test.go @@ -527,12 +527,20 @@ func TestBlobFetcherAvailabilityTimeout(t *testing.T) { isWaitingAvailability{testBlobTxHashes[0]: map[string]struct{}{"A": {}}}, isBlobScheduled{announces: nil, fetching: nil}, - // Run clock for timeout + // Run clock for timeout → partial converts to full, peer A moves to announces doWait{time: blobAvailabilityTimeout, step: true}, - // After timeout, waitlist should be empty + // After timeout, waitlist should be empty but tx promoted to full fetch isWaitingAvailability{}, - isBlobScheduled{announces: nil, fetching: nil}, + isDecidedFull{testBlobTxHashes[0]: struct{}{}}, + isBlobScheduled{ + announces: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + fetching: map[string][]blobAnnounce{ + "A": {{hash: testBlobTxHashes[0], custody: halfCustody}}, + }, + }, }, }) } diff --git a/eth/fetcher/metrics.go b/eth/fetcher/metrics.go index 4eb4db0a99..1126fdc382 100644 --- a/eth/fetcher/metrics.go +++ b/eth/fetcher/metrics.go @@ -60,7 +60,7 @@ var ( blobAnnounceInMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/in", nil) blobAnnounceDOSMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/dos", nil) - // This metric is to track the number of availability failure + // This metric tracks partial→full conversions due to availability timeout blobAnnounceTimeoutMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/announces/timeout", nil) blobRequestOutMeter = metrics.NewRegisteredMeter("eth/fetcher/blob/request/out", nil) From 6e00052cf5640ef84e7de44f08e006f82533dd89 Mon Sep 17 00:00:00 2001 From: healthykim Date: Tue, 31 Mar 2026 20:27:13 +0900 Subject: [PATCH 10/24] add engine_getBlobsV4 --- beacon/engine/types.go | 5 ++ core/txpool/blobpool/blobpool.go | 80 +++++++++++++++++++++++++++++++- eth/catalyst/api.go | 60 ++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/beacon/engine/types.go b/beacon/engine/types.go index 5c94e67de1..71ee44d877 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -157,6 +157,11 @@ type BlobAndProofV2 struct { CellProofs []hexutil.Bytes `json:"proofs"` // proofs MUST contain exactly CELLS_PER_EXT_BLOB cell proofs. } +type BlobCellsAndProofsV1 struct { + BlobCells []hexutil.Bytes `json:"blob_cells"` + Proofs []hexutil.Bytes `json:"proofs"` +} + // JSON type overrides for ExecutionPayloadEnvelope. type executionPayloadEnvelopeMarshaling struct { BlockValue *hexutil.Big diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 7328eba460..1da727be71 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1750,7 +1750,85 @@ func (p *BlobPool) GetBlobs(vhashes []common.Hash, version byte) ([]*kzg4844.Blo return blobs, commitments, proofs, nil } -// AvailableBlobs returns the number of blobs that are available in the subpool. +// GetBlobCells returns cells for the given versioned blob hashes, +// filtered by the requested cell indices(mask). +// Each entry in the result corresponds to one vhash. Nil entries mean the blob +// was not available. +func (p *BlobPool) GetBlobCells(vhashes []common.Hash, mask types.CustodyBitmap) ([][]*kzg4844.Cell, [][]*kzg4844.Proof, error) { + var ( + cells = make([][]*kzg4844.Cell, len(vhashes)) + proofs = make([][]*kzg4844.Proof, len(vhashes)) + vindex = make(map[common.Hash][]int) // Indices of versioned hashes in the request + filled = make(map[common.Hash]struct{}) + ) + for i, h := range vhashes { + vindex[h] = append(vindex[h], i) + } + requestedIndices := mask.Indices() + + for _, vhash := range vhashes { + if _, ok := filled[vhash]; ok { + continue + } + p.lock.RLock() + txID, exists := p.lookup.storeidOfBlob(vhash) + p.lock.RUnlock() + if !exists { + continue + } + data, err := p.store.Get(txID) + if err != nil { + continue + } + var pooledTx pooledBlobTx + if err := rlp.DecodeBytes(data, &pooledTx); err != nil { + continue + } + sidecar := pooledTx.Sidecar + if sidecar == nil { + continue + } + tx := pooledTx.Transaction + cellsPerBlob := sidecar.Custody.OneCount() + storedIndices := sidecar.Custody.Indices() + + for blobIdx, hash := range tx.BlobHashes() { + indices, ok := vindex[hash] + if !ok { + continue + } + filled[hash] = struct{}{} + + blobCells := make([]*kzg4844.Cell, len(requestedIndices)) + blobProofs := make([]*kzg4844.Proof, len(requestedIndices)) + + for i, cellIdx := range requestedIndices { + pos := -1 + for k, storedIdx := range storedIndices { + if storedIdx == cellIdx { + pos = k + break + } + } + if pos >= 0 { + cell := sidecar.Cells[blobIdx*cellsPerBlob+pos] + blobCells[i] = &cell + proofIdx := blobIdx*kzg4844.CellProofsPerBlob + int(cellIdx) + if proofIdx < len(sidecar.Proofs) { + proof := sidecar.Proofs[proofIdx] + blobProofs[i] = &proof + } + } + } + for _, idx := range indices { + cells[idx] = blobCells + proofs[idx] = blobProofs + } + } + } + return cells, proofs, nil +} + func (p *BlobPool) AvailableBlobs(vhashes []common.Hash) int { available := 0 for _, vhash := range vhashes { diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index d71edeac6b..6aaa287eac 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -660,6 +660,66 @@ func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) ([]*engine.Blob return res, nil } +// GetBlobsV4 returns cell-level blob data from the transaction pool. +// V4 returns only the requested cells as specified by the indices_bitarray. +func (api *ConsensusAPI) GetBlobsV4(hashes []common.Hash, indicesBitarray hexutil.Bytes) ([]*engine.BlobCellsAndProofsV1, error) { + head := api.eth.BlockChain().CurrentHeader() + // Sparse blobpool is not necessarily coupled with the Amsterdam fork and + // can technically be supported after the Osaka fork + // (where cell proofs are introduced). + if api.config().LatestFork(head.Time) < forks.Osaka { + return nil, nil + } + if len(hashes) > 128 { + return nil, engine.TooLargeRequest.With(fmt.Errorf("requested blob count too large: %v", len(hashes))) + } + if len(indicesBitarray) != 16 { + return nil, engine.InvalidParams.With(fmt.Errorf("indices_bitarray must be 16 bytes, got %d", len(indicesBitarray))) + } + var mask types.CustodyBitmap + copy(mask[:], indicesBitarray) + cells, proofs, err := api.eth.BlobTxPool().GetBlobCells(hashes, mask) + if err != nil { + return nil, engine.InvalidParams.With(err) + } + var ( + res = make([]*engine.BlobCellsAndProofsV1, len(hashes)) + hitCount int + ) + getBlobsRequestedCounter.Inc(int64(len(hashes))) + for i := range hashes { + if cells[i] == nil || proofs[i] == nil { + continue + } + hitCount++ + blobCells := make([]hexutil.Bytes, len(cells[i])) + for j, cell := range cells[i] { + if cell != nil { + blobCells[j] = cell[:] + } + } + blobProofs := make([]hexutil.Bytes, len(proofs[i])) + for j, proof := range proofs[i] { + if proof != nil { + blobProofs[j] = proof[:] + } + } + res[i] = &engine.BlobCellsAndProofsV1{ + BlobCells: blobCells, + Proofs: blobProofs, + } + } + getBlobsAvailableCounter.Inc(int64(hitCount)) + if hitCount == len(hashes) { + getBlobsRequestCompleteHit.Inc(1) + } else if hitCount > 0 { + getBlobsRequestPartialHit.Inc(1) + } else { + getBlobsRequestMiss.Inc(1) + } + return res, nil +} + // Helper for NewPayload* methods. var invalidStatus = engine.PayloadStatusV1{Status: engine.INVALID} From 64982f03aeb6ee494c90f7085bd77b4f680465bc Mon Sep 17 00:00:00 2001 From: healthykim Date: Wed, 1 Apr 2026 15:28:50 +0900 Subject: [PATCH 11/24] skip partial blob txs during blob building --- core/txpool/blobpool/blobpool.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 1da727be71..9cbb05881f 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -2338,6 +2338,10 @@ func (p *BlobPool) Pending(filter txpool.PendingFilter) (map[common.Address][]*t break // execution gas limit is too high } } + // Skip transactions without enough cells to recover blobs + if tx.custody != nil && tx.custody.OneCount() < kzg4844.DataPerBlob { + break // not enough cells to build a full payload, discard rest of txs from the account + } // Transaction was accepted according to the filter, append to the pending list lazies = append(lazies, &txpool.LazyTransaction{ Pool: p, From daee5257415d6269b99966f345cdd7c0b8ff5e68 Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 2 Apr 2026 21:48:56 +0900 Subject: [PATCH 12/24] add buffer.go and proper peer dropping mechanism --- core/txpool/blobpool/blobpool.go | 344 +++----------------- core/txpool/blobpool/blobpool_test.go | 2 +- core/txpool/blobpool/buffer.go | 262 +++++++++++++++ core/txpool/blobpool/buffer_test.go | 254 +++++++++++++++ core/txpool/blobpool/limbo.go | 8 +- eth/fetcher/blob_fetcher.go | 55 ++-- eth/fetcher/blob_fetcher_test.go | 70 ++-- eth/fetcher/tx_fetcher.go | 14 +- eth/fetcher/tx_fetcher_test.go | 10 +- eth/handler.go | 45 ++- eth/handler_test.go | 10 +- tests/fuzzers/txfetcher/txfetcher_fuzzer.go | 2 +- 12 files changed, 663 insertions(+), 413 deletions(-) create mode 100644 core/txpool/blobpool/buffer.go create mode 100644 core/txpool/blobpool/buffer_test.go diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 9cbb05881f..2f44991d21 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -152,7 +152,7 @@ type blobTxMeta struct { // newBlobTxMeta retrieves the indexed metadata fields from a pooled blob transaction // and assembles a helper struct to track in memory. -func newBlobTxMeta(id uint64, size uint64, storageSize uint32, pooledTx *pooledBlobTx) *blobTxMeta { +func newBlobTxMeta(id uint64, size uint64, storageSize uint32, pooledTx *PooledBlobTx) *blobTxMeta { var version byte if pooledTx.Sidecar != nil { version = pooledTx.Sidecar.Version @@ -180,7 +180,7 @@ func newBlobTxMeta(id uint64, size uint64, storageSize uint32, pooledTx *pooledB return meta } -type pooledBlobTx struct { +type PooledBlobTx struct { Transaction *types.Transaction Sidecar *types.BlobTxCellSidecar Size uint64 // original transaction size (including blobs) @@ -188,7 +188,7 @@ type pooledBlobTx struct { } // newPooledBlobTx creates pooledBlobTx struct. -func newPooledBlobTx(tx *types.Transaction) (*pooledBlobTx, error) { +func newPooledBlobTx(tx *types.Transaction) (*PooledBlobTx, error) { if tx.BlobTxSidecar() == nil { return nil, errors.New("missing blob sidecar") } @@ -196,7 +196,7 @@ func newPooledBlobTx(tx *types.Transaction) (*pooledBlobTx, error) { if err != nil { return nil, err } - return &pooledBlobTx{ + return &PooledBlobTx{ Transaction: tx.WithoutBlobTxSidecar(), Sidecar: sidecar, Size: tx.Size(), @@ -205,7 +205,7 @@ func newPooledBlobTx(tx *types.Transaction) (*pooledBlobTx, error) { } // convert recovers blobs from cell sidecar and returns a full transaction with blob sidecar. -func (ptx *pooledBlobTx) convert() (*types.Transaction, error) { +func (ptx *PooledBlobTx) convert() (*types.Transaction, error) { if ptx.Sidecar == nil { return nil, errors.New("cell sidecar missing") } @@ -409,17 +409,9 @@ type BlobPool struct { stored uint64 // Useful data size of all transactions on disk limbo *limbo // Persistent data store for the non-finalized blobs - gapped map[common.Address][]*pooledBlobTx // Transactions that are currently gapped (nonce too high) + gapped map[common.Address][]*PooledBlobTx // Transactions that are currently gapped (nonce too high) gappedSource map[common.Hash]common.Address // Source of gapped transactions to allow rechecking on inclusion - queue map[common.Hash]*types.Transaction // buffer - indexQueue map[common.Address][]*blobTxMeta // tx hashes in queue per address, sorted by nonce - spentQueue map[common.Address]*uint256.Int // Expenditure tracking for accounts, only for buffered txs - replacementQueue map[common.Address]map[uint64]*blobTxMeta // Replacement queue for pooled transactions - - cellQueue map[common.Hash][]kzg4844.Cell // cell buffer - custodyQueue map[common.Hash]*types.CustodyBitmap - signer types.Signer // Transaction signer to use for sender recovery chain BlockChain // Chain object to access the state through @@ -446,21 +438,15 @@ func New(config Config, chain BlockChain, hasPendingAuth func(common.Address) bo // Create the transaction pool with its initial settings return &BlobPool{ - config: config, - hasPendingAuth: hasPendingAuth, - signer: types.LatestSigner(chain.Config()), - chain: chain, - lookup: newLookup(), - index: make(map[common.Address][]*blobTxMeta), - spent: make(map[common.Address]*uint256.Int), - gapped: make(map[common.Address][]*pooledBlobTx), - gappedSource: make(map[common.Hash]common.Address), - queue: make(map[common.Hash]*types.Transaction), - indexQueue: make(map[common.Address][]*blobTxMeta), - spentQueue: make(map[common.Address]*uint256.Int), - cellQueue: make(map[common.Hash][]kzg4844.Cell), - custodyQueue: make(map[common.Hash]*types.CustodyBitmap), - replacementQueue: make(map[common.Address]map[uint64]*blobTxMeta), + config: config, + hasPendingAuth: hasPendingAuth, + signer: types.LatestSigner(chain.Config()), + chain: chain, + lookup: newLookup(), + index: make(map[common.Address][]*blobTxMeta), + spent: make(map[common.Address]*uint256.Int), + gapped: make(map[common.Address][]*PooledBlobTx), + gappedSource: make(map[common.Hash]common.Address), } } @@ -474,7 +460,6 @@ func (p *BlobPool) FilterType(kind byte) bool { return kind == types.BlobTxType } -// Init sets the gas price needed to keep a transaction in the pool and the chain // head to allow balance / nonce checks. The transaction journal will be loaded // from disk and filtered based on the provided starting settings. func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reserver) error { @@ -659,7 +644,7 @@ func (p *BlobPool) Close() error { // If a pooledBlobTx is found, it is indexed directly and nil is returned. func (p *BlobPool) parseTransaction(id uint64, size uint32, blob []byte) (*types.Transaction, error) { tx := new(types.Transaction) - pooledTx := new(pooledBlobTx) + pooledTx := new(PooledBlobTx) if err := rlp.DecodeBytes(blob, pooledTx); err != nil { // This path is impossible unless the disk data representation changes @@ -975,7 +960,7 @@ func (p *BlobPool) offload(addr common.Address, nonce uint64, id uint64, inclusi log.Error("Blobs missing for included transaction", "from", addr, "nonce", nonce, "id", id, "err", err) return } - var pooledTx pooledBlobTx + var pooledTx PooledBlobTx if err = rlp.DecodeBytes(data, &pooledTx); err != nil { log.Error("Blobs corrupted for included transaction", "from", addr, "nonce", nonce, "id", id, "err", err) return @@ -1063,7 +1048,7 @@ func (p *BlobPool) Reset(oldHead, newHead *types.Header) { log.Error("Blobs missing for announcable transaction", "from", addr, "nonce", meta.nonce, "id", meta.id, "err", err) continue } - var pooledTx pooledBlobTx + var pooledTx PooledBlobTx if err = rlp.DecodeBytes(data, &pooledTx); err != nil { log.Error("Blobs corrupted for announcable transaction", "from", addr, "nonce", meta.nonce, "id", meta.id, "err", err) continue @@ -1377,77 +1362,31 @@ func (p *BlobPool) checkDelegationLimit(tx *types.Transaction) error { // rules and adheres to some heuristic limits of the local node (price and size). // This function assumes the static validation has been performed already and // only runs the stateful checks with lock protection. -// If buffer field is set to true, consider txs in the queue as well. -// This is to prevent fetching cells of invalid transactions, which would be expensive. -func (p *BlobPool) validateTx(tx *types.Transaction, buffer bool) error { +func (p *BlobPool) validateTx(tx *types.Transaction) error { if err := p.ValidateTxBasics(tx); err != nil { return err } - // Ensure the transaction adheres to the stateful pool filters (nonce, balance) stateOpts := &txpool.ValidationOptionsWithState{ State: p.state, FirstNonceGap: func(addr common.Address) uint64 { - // Nonce gaps are permitted in the blob pool, but only as part of the - // in-memory 'gapped' buffer. We expose the gap here to validateTx, - // then handle the error by adding to the buffer. The first gap will - // be the next nonce shifted by however many transactions we already - // have pooled. - result := p.state.GetNonce(addr) + uint64(len(p.index[addr])) - if buffer { - return result + uint64(len(p.indexQueue[addr])) - } - return result + return p.state.GetNonce(addr) + uint64(len(p.index[addr])) }, UsedAndLeftSlots: func(addr common.Address) (int, int) { have := len(p.index[addr]) - if buffer { - have += len(p.indexQueue[addr]) - } if have >= maxTxsPerAccount { return have, 0 } return have, maxTxsPerAccount - have }, ExistingExpenditure: func(addr common.Address) *big.Int { - result := new(big.Int) if spent := p.spent[addr]; spent != nil { - result.Add(result, spent.ToBig()) + return spent.ToBig() } - - // calculate expenditure after replacements - if buffer { - if replacements := p.replacementQueue[addr]; replacements != nil { - next := p.state.GetNonce(addr) - - for nonce, replacement := range replacements { - if nonce >= next && len(p.index[addr]) > int(nonce-next) { - originalCost := p.index[addr][nonce-next].costCap - replacementCost := replacement.costCap - - result.Add(result, new(uint256.Int).Sub(replacementCost, originalCost).ToBig()) - } - } - } - - if spentQueue := p.spentQueue[addr]; spentQueue != nil { - result.Add(result, spentQueue.ToBig()) - } - } - - return result + return new(big.Int) }, ExistingCost: func(addr common.Address, nonce uint64) *big.Int { next := p.state.GetNonce(addr) - if buffer { - if p.replacementQueue[addr] != nil && p.replacementQueue[addr][nonce] != nil { - return p.replacementQueue[addr][nonce].costCap.ToBig() - } - pooledCount := uint64(len(p.index[addr])) - if nonce >= next+pooledCount && uint64(len(p.indexQueue[addr])) > nonce-next-pooledCount { - return p.indexQueue[addr][nonce-next-pooledCount].costCap.ToBig() - } - } if uint64(len(p.index[addr])) > nonce-next { return p.index[addr][int(nonce-next)].costCap.ToBig() } @@ -1460,33 +1399,15 @@ func (p *BlobPool) validateTx(tx *types.Transaction, buffer bool) 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 ( - from, _ = types.Sender(p.signer, tx) // already validated above + from, _ = types.Sender(p.signer, tx) next = p.state.GetNonce(from) ) var prev *blobTxMeta nonce := tx.Nonce() if nonce < next+uint64(len(p.index[from])) { - // pooled tx prev = p.index[from][nonce-next] - - // check replacement if it is buffer tx validation - if buffer && p.replacementQueue[from] != nil { - if replacement := p.replacementQueue[from][nonce]; replacement != nil { - prev = replacement - } - } - } else if buffer { - pooledCount := uint64(len(p.index[from])) - if nonce >= next+pooledCount { - offset := nonce - next - pooledCount - if uint64(len(p.indexQueue[from])) > offset && offset > 0 { - prev = p.indexQueue[from][offset] - } - } } if prev == nil { return nil @@ -1530,7 +1451,7 @@ func (p *BlobPool) Has(hash common.Hash) bool { p.lock.RLock() defer p.lock.RUnlock() - if p.lookup.exists(hash) || p.queue[hash] != nil { + if p.lookup.exists(hash) { return true } @@ -1543,7 +1464,7 @@ func (p *BlobPool) HasPayload(hash common.Hash) bool { p.lock.RLock() defer p.lock.RUnlock() - return p.lookup.exists(hash) || len(p.cellQueue[hash]) != 0 + return p.lookup.exists(hash) } // getRLP returns the raw RLP-encoded pooledBlobTx data from the store. @@ -1587,7 +1508,7 @@ func (p *BlobPool) Get(hash common.Hash, includeBlob bool) *types.Transaction { if len(data) == 0 { return nil } - var pooledTx pooledBlobTx + var pooledTx PooledBlobTx if err := rlp.DecodeBytes(data, &pooledTx); err != nil { log.Error("Blobs corrupted for traced transaction", "hash", hash, "err", err) return nil @@ -1613,7 +1534,7 @@ func (p *BlobPool) GetRLP(hash common.Hash, includeBlob bool) []byte { if len(data) == 0 { return nil } - var pooledTx pooledBlobTx + var pooledTx PooledBlobTx if err := rlp.DecodeBytes(data, &pooledTx); err != nil { log.Error("Failed to decode transaction in blobpool", "hash", hash, "err", err) return nil @@ -1701,7 +1622,7 @@ func (p *BlobPool) GetBlobs(vhashes []common.Hash, version byte) ([]*kzg4844.Blo } // Decode the blob transaction - var pooledTx pooledBlobTx + var pooledTx PooledBlobTx if err := rlp.DecodeBytes(data, &pooledTx); err != nil { log.Error("Blobs corrupted for traced transaction", "id", txID, "err", err) continue @@ -1780,7 +1701,7 @@ func (p *BlobPool) GetBlobCells(vhashes []common.Hash, mask types.CustodyBitmap) if err != nil { continue } - var pooledTx pooledBlobTx + var pooledTx PooledBlobTx if err := rlp.DecodeBytes(data, &pooledTx); err != nil { continue } @@ -1851,127 +1772,19 @@ func (p *BlobPool) Add(txs []*types.Transaction, sync bool) []error { if errs[i] = p.ValidateTxBasics(tx); errs[i] != nil { continue } - sc := tx.BlobTxSidecar() - if sc != nil && len(sc.Blobs) != 0 { - pooledTx, err := newPooledBlobTx(tx) - if err != nil { - errs[i] = err - continue - } - errs[i] = p.add(pooledTx) - } else { - errs[i] = p.addBuffer(tx) + pooledTx, err := newPooledBlobTx(tx) + if err != nil { + errs[i] = err + continue } + errs[i] = p.AddPooledTx(pooledTx) } return errs } -func (p *BlobPool) addBuffer(tx *types.Transaction) (err error) { - p.lock.Lock() - defer p.lock.Unlock() - - if cells, ok := p.cellQueue[tx.Hash()]; ok { - sidecar := tx.BlobTxSidecar() - - var cellSidecar types.BlobTxCellSidecar - blobCount := len(sidecar.Commitments) - if len(cells) >= kzg4844.DataPerBlob*blobCount { - blob, err := kzg4844.RecoverBlobs(cells, p.custodyQueue[tx.Hash()].Indices()) - if err != nil { - return err - } - extendedCells, err := kzg4844.ComputeCells(blob) - if err != nil { - return err - } - cellSidecar = types.BlobTxCellSidecar{ - Version: sidecar.Version, - Cells: extendedCells, - Commitments: sidecar.Commitments, - Proofs: sidecar.Proofs, - Custody: *types.CustodyBitmapAll, - } - } else { - cellSidecar = types.BlobTxCellSidecar{ - Version: sidecar.Version, - Cells: cells, - Commitments: sidecar.Commitments, - Proofs: sidecar.Proofs, - Custody: *p.custodyQueue[tx.Hash()], - } - } - - err := p.addLocked(&pooledBlobTx{Transaction: tx.WithoutBlobTxSidecar(), Sidecar: &cellSidecar, Size: tx.Size()}, true) - if err == nil { - delete(p.cellQueue, tx.Hash()) - delete(p.custodyQueue, tx.Hash()) - } - return err - } - - if err := p.validateTx(tx, true); err != nil { - return err - } - // Store the original tx in queue (with BlobTxSidecar intact — Blobs may be nil - // from ETH/71 but commitments/proofs are preserved for cell validation later). - p.queue[tx.Hash()] = tx - from, _ := types.Sender(p.signer, tx) - - // Build a partial pooledBlobTx for metadata tracking. - var cellSidecar *types.BlobTxCellSidecar - if sidecar := tx.BlobTxSidecar(); sidecar != nil { - cellSidecar = &types.BlobTxCellSidecar{ - Version: sidecar.Version, - Commitments: sidecar.Commitments, - Proofs: sidecar.Proofs, - } - } - next := p.state.GetNonce(from) - nonce := tx.Nonce() - pooledCount := uint64(len(p.index[from])) - //todo this is strange - meta := newBlobTxMeta(0, tx.Size(), 0, &pooledBlobTx{Transaction: tx, Sidecar: cellSidecar, Size: tx.Size()}) - - if nonce < next+pooledCount { - // Pooled transaction replacements are stored in replacementQueue for expenditure validation - // for future transactions from the same account. This overestimates expenditure considering - // that replacement transaction payload fetch may fail and the tx can be dropped. - // However, this conservative approach prevents transactions that passed validation when - // entering the buffer from failing expenditure validation due to transaction replacements. - if p.replacementQueue[from] == nil { - p.replacementQueue[from] = make(map[uint64]*blobTxMeta) - } - if existingReplacement := p.replacementQueue[from][nonce]; existingReplacement != nil { - delete(p.queue, existingReplacement.hash) - } - p.replacementQueue[from][nonce] = meta - } else { - if p.spentQueue[from] == nil { - p.spentQueue[from] = new(uint256.Int) - } - bufferOffset := int(nonce - (next + pooledCount)) - if len(p.indexQueue[from]) > bufferOffset { - // Replace buffer transaction - prev := p.indexQueue[from][bufferOffset] - - delete(p.queue, prev.hash) - - p.indexQueue[from][bufferOffset] = meta - p.spentQueue[from] = new(uint256.Int).Sub(p.spentQueue[from], prev.costCap) - p.spentQueue[from] = new(uint256.Int).Add(p.spentQueue[from], meta.costCap) - - dropReplacedMeter.Mark(1) - } else { - p.indexQueue[from] = append(p.indexQueue[from], meta) - p.spentQueue[from] = new(uint256.Int).Add(p.spentQueue[from], meta.costCap) - } - } - return nil -} - // add inserts a new blob transaction into the pool if it passes validation (both // consensus validity and pool restrictions). -func (p *BlobPool) add(pooledTx *pooledBlobTx) (err error) { +func (p *BlobPool) AddPooledTx(pooledTx *PooledBlobTx) (err error) { // The blob pool blocks on adding a transaction. This is because blob txs are // only even pulled from the network, so this method will act as the overload // protection for fetches. @@ -1990,12 +1803,12 @@ func (p *BlobPool) add(pooledTx *pooledBlobTx) (err error) { // addLocked inserts a new blob transaction into the pool if it passes validation (both // consensus validity and pool restrictions). It must be called with the pool lock held. // Only for internal use. -func (p *BlobPool) addLocked(pooledTx *pooledBlobTx, checkGapped bool) (err error) { +func (p *BlobPool) addLocked(pooledTx *PooledBlobTx, checkGapped bool) (err error) { tx := pooledTx.Transaction cellSidecar := pooledTx.Sidecar // Ensure the transaction is valid from all perspectives - if err := p.validateTx(tx, false); err != nil { + if err := p.validateTx(tx); err != nil { log.Trace("Transaction validation failed", "hash", tx.Hash(), "err", err) switch { case errors.Is(err, txpool.ErrUnderpriced): @@ -2550,9 +2363,6 @@ func (p *BlobPool) Status(hash common.Hash) txpool.TxStatus { if p.lookup.exists(hash) { return txpool.TxStatusPending } - if _, ok := p.queue[hash]; ok { - return txpool.TxStatusQueued - } if _, gapped := p.gappedSource[hash]; gapped { return txpool.TxStatusQueued } @@ -2607,7 +2417,7 @@ func (p *BlobPool) Clear() { // Reset counters and the gapped buffer p.stored = 0 - p.gapped = make(map[common.Address][]*pooledBlobTx) + p.gapped = make(map[common.Address][]*PooledBlobTx) p.gappedSource = make(map[common.Hash]common.Address) var ( @@ -2640,7 +2450,7 @@ func (p *BlobPool) GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg48 return nil, errors.New("tracked blob transaction missing from store") } // Decode the blob transaction - var pooledTx pooledBlobTx + var pooledTx PooledBlobTx if err := rlp.DecodeBytes(data, &pooledTx); err != nil { return nil, errors.New("blobs corrupted for traced transaction") } @@ -2661,79 +2471,3 @@ func (p *BlobPool) GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg48 } return cells, nil } - -// AddPayload adds cell payloads for blob transactions. -func (p *BlobPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { - p.lock.Lock() - defer p.lock.Unlock() - errs := make([]error, len(txs)) - for i, hash := range txs { - if _, ok := p.queue[hash]; !ok { - p.cellQueue[hash] = cells[i] - p.custodyQueue[hash] = custody - continue - } - - sidecar := p.queue[hash].BlobTxSidecar() - - var cellSidecar types.BlobTxCellSidecar - blobCount := len(sidecar.Commitments) - if len(cells[i]) >= kzg4844.DataPerBlob*blobCount { - blob, err := kzg4844.RecoverBlobs(cells[i], custody.Indices()) - if err != nil { - errs[i] = err - continue - } - extendedCells, err := kzg4844.ComputeCells(blob) - if err != nil { - errs[i] = err - continue - } - cellSidecar = types.BlobTxCellSidecar{ - Version: sidecar.Version, - Cells: extendedCells, - Commitments: sidecar.Commitments, - Proofs: sidecar.Proofs, - Custody: *types.CustodyBitmapAll, - } - } else { - cellSidecar = types.BlobTxCellSidecar{ - Version: sidecar.Version, - Cells: cells[i], - Commitments: sidecar.Commitments, - Proofs: sidecar.Proofs, - Custody: *custody, - } - } - - errs[i] = p.addLocked(&pooledBlobTx{Transaction: p.queue[hash].WithoutBlobTxSidecar(), Sidecar: &cellSidecar, Size: p.queue[hash].Size()}, true) - - // clean up queues - tx := p.queue[hash] - delete(p.queue, hash) - from, _ := types.Sender(p.signer, tx) - nonce := tx.Nonce() - next := p.state.GetNonce(from) - - if p.replacementQueue[from] != nil { - delete(p.replacementQueue[from], nonce) - if len(p.replacementQueue[from]) == 0 { - delete(p.replacementQueue, from) - } - continue - } - - // plain tx - pooledCount := uint64(len(p.index[from])) - if nonce < next+pooledCount { - continue - } - offset := int(nonce - next - pooledCount) - if offset > 0 && offset < len(p.indexQueue[from]) { - removed := p.indexQueue[from][offset] - p.indexQueue[from] = append(p.indexQueue[from][:offset], p.indexQueue[from][offset+1:]...) - p.spentQueue[from] = new(uint256.Int).Sub(p.spentQueue[from], removed.costCap) - } - } - return errs -} diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index 98abd96bc4..35e9958bf3 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -2122,7 +2122,7 @@ func benchmarkPoolPending(b *testing.B, datacap uint64) { } statedb.AddBalance(addr, uint256.NewInt(1_000_000_000), tracing.BalanceChangeUnspecified) pooledTx, _ := newPooledBlobTx(tx) - pool.add(pooledTx) + pool.AddPooledTx(pooledTx) } statedb.Commit(0, true, false) defer pool.Close() diff --git a/core/txpool/blobpool/buffer.go b/core/txpool/blobpool/buffer.go new file mode 100644 index 0000000000..30dba84ae8 --- /dev/null +++ b/core/txpool/blobpool/buffer.go @@ -0,0 +1,262 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package blobpool + +import ( + "cmp" + "fmt" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto/kzg4844" + "github.com/ethereum/go-ethereum/log" +) + +const ( + bufferLifetime = 2 * time.Minute +) + +// PeerDelivery holds cells delivered by a single peer, in blob-major order. +type PeerDelivery struct { + Cells []kzg4844.Cell + Indices []uint64 +} + +type txEntry struct { + tx *types.Transaction + peer string + added time.Time +} + +type cellEntry struct { + deliveries map[string]*PeerDelivery + custody *types.CustodyBitmap + added time.Time +} + +type BlobBuffer struct { + txs map[common.Hash]*txEntry + cells map[common.Hash]*cellEntry + + addToPool func(*PooledBlobTx) error + dropPeer func(string) +} + +func NewBlobBuffer(addToPool func(*PooledBlobTx) error, dropPeer func(string)) *BlobBuffer { + return &BlobBuffer{ + txs: make(map[common.Hash]*txEntry), + cells: make(map[common.Hash]*cellEntry), + addToPool: addToPool, + dropPeer: dropPeer, + } +} + +// AddTx buffers a blob transaction (without blobs) from an ETH/71 peer. +// If cells are already buffered, verification and pool insertion are attempted. +func (b *BlobBuffer) AddTx(tx *types.Transaction, peer string) error { + b.evict() + + hash := tx.Hash() + sidecar := tx.BlobTxSidecar() + if sidecar == nil { + return fmt.Errorf("blob transaction without sidecar") + } + // vhash check + if err := sidecar.ValidateBlobCommitmentHashes(tx.BlobHashes()); err != nil { + log.Warn("Commitment hash mismatch, dropping peer", "peer", peer, "err", err) + b.dropPeer(peer) + return err + } + // proof count check + if len(sidecar.Proofs) < len(sidecar.Commitments)*kzg4844.CellProofsPerBlob { + b.dropPeer(peer) + return fmt.Errorf("insufficient proofs in sidecar") + } + // todo: I also considered performing additional validation for the metrics of the + // tx_fetcher. This could be used to avoid sending GetCells requests when the + // nonce is too low or the transaction is underpriced. However, doing so would + // require taking buffered transactions into account as well, and would require + // allowing the buffer to be part of the fetcher’s scheduling logic. + // Therefore, I will leave this as a TODO for now. + + if entry, ok := b.cells[hash]; ok { + return b.add(hash, tx, entry) + } + b.txs[hash] = &txEntry{tx: tx, peer: peer, added: time.Now()} + return nil +} + +// AddCells buffers per-peer cell deliveries from the blob fetcher. +// If the transaction is already buffered, verification and pool insertion are attempted. +func (b *BlobBuffer) AddCells(hash common.Hash, deliveries map[string]*PeerDelivery, custody *types.CustodyBitmap) error { + b.evict() + b.cells[hash] = &cellEntry{ + deliveries: deliveries, + custody: custody, + added: time.Now(), + } + + if txe, ok := b.txs[hash]; ok { + return b.add(hash, txe.tx, b.cells[hash]) + } + return nil +} + +// add verifies cells per-peer, sorts them, and adds to the pool. +func (b *BlobBuffer) add(hash common.Hash, tx *types.Transaction, cells *cellEntry) error { + sidecar := tx.BlobTxSidecar() + + // Per-peer cell verification + if badPeers := b.verifyCells(cells, sidecar); len(badPeers) > 0 { + b.dropPeers(badPeers) + delete(b.cells, hash) + delete(b.txs, hash) + return fmt.Errorf("cell verification failed") + } + blobCount := len(tx.BlobHashes()) + sorted, custody := sortCells(cells, blobCount) + + cellSidecar := &types.BlobTxCellSidecar{ + Version: sidecar.Version, + Cells: sorted, + Commitments: sidecar.Commitments, + Proofs: sidecar.Proofs, + Custody: *custody, + } + pooledTx := &PooledBlobTx{ + Transaction: tx.WithoutBlobTxSidecar(), + Sidecar: cellSidecar, + Size: tx.Size(), + SizeWithoutBlob: tx.WithoutBlob().Size(), + } + err := b.addToPool(pooledTx) + delete(b.cells, hash) + delete(b.txs, hash) + return err +} + +func (b *BlobBuffer) HasTx(hash common.Hash) bool { + _, ok := b.txs[hash] + return ok +} + +func (b *BlobBuffer) HasCells(hash common.Hash) bool { + _, ok := b.cells[hash] + return ok +} + +func (b *BlobBuffer) dropPeers(peers []string) { + if b.dropPeer == nil { + return + } + for _, p := range peers { + b.dropPeer(p) + } +} + +func (b *BlobBuffer) evict() { + now := time.Now() + for hash, entry := range b.txs { + if now.Sub(entry.added) > bufferLifetime { + delete(b.txs, hash) + } + } + for hash, entry := range b.cells { + if now.Sub(entry.added) > bufferLifetime { + delete(b.cells, hash) + } + } +} + +// verifyCells verifies each peer's cells against the sidecar. +// Returns the list of peers whose cells failed verification. +func (b *BlobBuffer) verifyCells(entry *cellEntry, sidecar *types.BlobTxSidecar) []string { + var badPeers []string + for peer, delivery := range entry.deliveries { + if err := verifyPeerCells(delivery, sidecar); err != nil { + log.Debug("Cell verification failed", "peer", peer, "err", err) + badPeers = append(badPeers, peer) + } + } + return badPeers +} + +// verifyPeerCells verifies a single peer's cells against the sidecar proofs. +// delivery.Cells is blob-major: [blob0_cell0..blob0_cellN, blob1_cell0..blob1_cellN, ...] +func verifyPeerCells(delivery *PeerDelivery, sidecar *types.BlobTxSidecar) error { + cellsPerBlob := len(delivery.Indices) + blobCount := len(delivery.Cells) / cellsPerBlob + if blobCount == 0 || blobCount != len(sidecar.Commitments) { + return fmt.Errorf("blob count mismatch: delivery %d, commitments %d", blobCount, len(sidecar.Commitments)) + } + // Extract proofs corresponding to this peer's cell indices + var proofs []kzg4844.Proof + for blobIdx := 0; blobIdx < blobCount; blobIdx++ { + for _, cellIdx := range delivery.Indices { + proofIdx := blobIdx*kzg4844.CellProofsPerBlob + int(cellIdx) + if proofIdx >= len(sidecar.Proofs) { + return fmt.Errorf("proof index out of range: %d", proofIdx) + } + proofs = append(proofs, sidecar.Proofs[proofIdx]) + } + } + return kzg4844.VerifyCells(delivery.Cells, sidecar.Commitments, proofs, delivery.Indices) +} + +// sortCells merges all per-peer deliveries into a single flat cell array +// sorted by custody index. +// +// e.g. +// peer A: cells = [blob0_cell5, blob0_cell3, blob1_cell5, blob1_cell3] +// peer B: cells = [blob0_cell1, blob0_cell7, blob1_cell1, blob1_cell7] +// -> [blob0_cell1, blob0_cell3, blob0_cell5, blob0_cell7, blob1_cell1, blob1_cell3, blob1_cell5, blob1_cell7] +func sortCells(entry *cellEntry, blobCount int) ([]kzg4844.Cell, *types.CustodyBitmap) { + // indices per delivery + var indices []uint64 + + // 1. compose per blob cells + blob := make([][]kzg4844.Cell, blobCount) + for _, d := range entry.deliveries { + n := len(d.Indices) + indices = append(indices, d.Indices...) + for b := range blobCount { + blob[b] = append(blob[b], d.Cells[b*n:(b+1)*n]...) + } + } + + // 2. sort + perm := make([]int, len(indices)) + for i := range perm { + perm[i] = i + } + // perm represents the position of cells in sorted array + slices.SortFunc(perm, func(a, b int) int { + return cmp.Compare(indices[a], indices[b]) + }) + // reorder cells + var res []kzg4844.Cell + for b := range blobCount { + for _, p := range perm { + res = append(res, blob[b][p]) + } + } + + custody := types.NewCustodyBitmap(indices) + return res, &custody +} diff --git a/core/txpool/blobpool/buffer_test.go b/core/txpool/blobpool/buffer_test.go new file mode 100644 index 0000000000..74680c530a --- /dev/null +++ b/core/txpool/blobpool/buffer_test.go @@ -0,0 +1,254 @@ +package blobpool + +import ( + "crypto/ecdsa" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/kzg4844" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" +) + +// makeV1Tx creates a V1 blob transaction with cell proofs, then strips blobs +// (simulating what ETH/71 peers send). +func makeV1Tx(t *testing.T, nonce uint64, blobCount int, blobOffset int, key *ecdsa.PrivateKey) *types.Transaction { + t.Helper() + tx := makeMultiBlobTx(nonce, 1, 1, 1, blobCount, blobOffset, key, types.BlobSidecarVersion1) + return tx.WithoutBlob() +} + +// makePeerDelivery creates a PeerDelivery for given cell indices from a set of blobs. +func makePeerDelivery(t *testing.T, blobOffset, blobCount int, indices []uint64) *PeerDelivery { + t.Helper() + var allCells []kzg4844.Cell + for i := 0; i < blobCount; i++ { + cells, err := kzg4844.ComputeCells([]kzg4844.Blob{*testBlobs[blobOffset+i]}) + if err != nil { + t.Fatal(err) + } + allCells = append(allCells, cells...) + } + var deliveryCells []kzg4844.Cell + for b := 0; b < blobCount; b++ { + for _, idx := range indices { + deliveryCells = append(deliveryCells, allCells[b*kzg4844.CellsPerBlob+int(idx)]) + } + } + return &PeerDelivery{Cells: deliveryCells, Indices: indices} +} + +func newTestBuffer(t *testing.T) *BlobBuffer { + t.Helper() + return NewBlobBuffer( + func(ptx *PooledBlobTx) error { return nil }, + func(peer string) {}, + ) +} + +func TestSortCells(t *testing.T) { + blobCount := 2 + blobOffset := 0 + + peerA := makePeerDelivery(t, blobOffset, blobCount, []uint64{5, 3}) + peerB := makePeerDelivery(t, blobOffset, blobCount, []uint64{1, 7}) + + custody := types.NewCustodyBitmap([]uint64{1, 3, 5, 7}) + entry := &cellEntry{ + deliveries: map[string]*PeerDelivery{ + "peerA": peerA, + "peerB": peerB, + }, + custody: &custody, + } + sorted, resultCustody := sortCells(entry, blobCount) + + resultIndices := resultCustody.Indices() + if len(resultIndices) != 4 { + t.Fatalf("expected 4 indices, got %d", len(resultIndices)) + } + for i, expected := range []uint64{1, 3, 5, 7} { + if resultIndices[i] != expected { + t.Errorf("index %d: expected %d, got %d", i, expected, resultIndices[i]) + } + } + + expected := makePeerDelivery(t, blobOffset, blobCount, []uint64{1, 3, 5, 7}) + if len(sorted) != len(expected.Cells) { + t.Fatalf("sorted length %d != expected %d", len(sorted), len(expected.Cells)) + } + for i := range sorted { + if sorted[i] != expected.Cells[i] { + t.Errorf("cell %d mismatch", i) + } + } +} + +func TestAddTxThenCells(t *testing.T) { + key, _ := crypto.GenerateKey() + blobCount := 2 + buf := newTestBuffer(t) + + tx := makeV1Tx(t, 0, blobCount, 0, key) + hash := tx.Hash() + + if err := buf.AddTx(tx, "peerA"); err != nil { + t.Fatal(err) + } + if !buf.HasTx(hash) { + t.Fatal("tx should be buffered") + } + + dataIndices := make([]uint64, kzg4844.DataPerBlob) + for i := range dataIndices { + dataIndices[i] = uint64(i) + } + delivery := makePeerDelivery(t, 0, blobCount, dataIndices) + custody := types.NewCustodyBitmap(dataIndices) + + if err := buf.AddCells(hash, map[string]*PeerDelivery{"peerB": delivery}, &custody); err != nil { + t.Fatal(err) + } + if buf.HasTx(hash) || buf.HasCells(hash) { + t.Fatal("buffer should be empty after add") + } +} + +func TestAddCellsThenTx(t *testing.T) { + key, _ := crypto.GenerateKey() + blobCount := 2 + buf := newTestBuffer(t) + + tx := makeV1Tx(t, 0, blobCount, 0, key) + hash := tx.Hash() + + dataIndices := make([]uint64, kzg4844.DataPerBlob) + for i := range dataIndices { + dataIndices[i] = uint64(i) + } + delivery := makePeerDelivery(t, 0, blobCount, dataIndices) + custody := types.NewCustodyBitmap(dataIndices) + + if err := buf.AddCells(hash, map[string]*PeerDelivery{"peerB": delivery}, &custody); err != nil { + t.Fatal(err) + } + if !buf.HasCells(hash) { + t.Fatal("cells should be buffered") + } + + if err := buf.AddTx(tx, "peerA"); err != nil { + t.Fatal(err) + } + if buf.HasTx(hash) || buf.HasCells(hash) { + t.Fatal("buffer should be empty after add") + } +} + +func TestMultiPeerDelivery(t *testing.T) { + key, _ := crypto.GenerateKey() + blobCount := 2 + buf := newTestBuffer(t) + + tx := makeV1Tx(t, 0, blobCount, 0, key) + hash := tx.Hash() + buf.AddTx(tx, "peerA") + + indicesA := []uint64{0, 2, 4, 6} + indicesB := []uint64{1, 3, 5, 7} + deliveryA := makePeerDelivery(t, 0, blobCount, indicesA) + deliveryB := makePeerDelivery(t, 0, blobCount, indicesB) + + allIndices := append(indicesA, indicesB...) + custody := types.NewCustodyBitmap(allIndices) + + if err := buf.AddCells(hash, map[string]*PeerDelivery{ + "peerB": deliveryA, + "peerC": deliveryB, + }, &custody); err != nil { + t.Fatal(err) + } + if buf.HasTx(hash) || buf.HasCells(hash) { + t.Fatal("buffer should be empty after add") + } +} + +func TestBadCell(t *testing.T) { + key, _ := crypto.GenerateKey() + blobCount := 1 + + var dropped []string + buf := NewBlobBuffer( + func(ptx *PooledBlobTx) error { return nil }, + func(peer string) { dropped = append(dropped, peer) }, + ) + + tx := makeV1Tx(t, 0, blobCount, 0, key) + hash := tx.Hash() + buf.AddTx(tx, "peerA") + + goodDelivery := makePeerDelivery(t, 0, blobCount, []uint64{0, 1, 2, 3}) + badDelivery := makePeerDelivery(t, 0, blobCount, []uint64{4, 5, 6, 7}) + for i := range badDelivery.Cells { + for j := range badDelivery.Cells[i] { + badDelivery.Cells[i][j] ^= 0xFF + } + } + + allIndices := []uint64{0, 1, 2, 3, 4, 5, 6, 7} + custody := types.NewCustodyBitmap(allIndices) + + err := buf.AddCells(hash, map[string]*PeerDelivery{ + "peerB": goodDelivery, + "peerC": badDelivery, + }, &custody) + if err == nil { + t.Fatal("expected error from bad cells") + } + + if len(dropped) != 1 || dropped[0] != "peerC" { + t.Fatalf("only peerC should have been dropped, got: %v", dropped) + } + if buf.HasTx(hash) || buf.HasCells(hash) { + t.Fatal("buffer should be empty after bad cell drop") + } +} + +func TestBadTx(t *testing.T) { + key, _ := crypto.GenerateKey() + + var dropped []string + buf := NewBlobBuffer( + func(ptx *PooledBlobTx) error { return nil }, + func(peer string) { dropped = append(dropped, peer) }, + ) + + blobtx := &types.BlobTx{ + ChainID: uint256.MustFromBig(params.MainnetChainConfig.ChainID), + Nonce: 0, + GasTipCap: uint256.NewInt(1), + GasFeeCap: uint256.NewInt(1), + Gas: 21000, + BlobFeeCap: uint256.NewInt(1), + BlobHashes: []common.Hash{testBlobVHashes[0]}, + Value: uint256.NewInt(100), + Sidecar: types.NewBlobTxSidecar(types.BlobSidecarVersion1, + nil, + []kzg4844.Commitment{testBlobCommits[1]}, + testBlobCellProofs[1], + ), + } + tx := types.MustSignNewTx(key, types.LatestSigner(params.MainnetChainConfig), blobtx) + + err := buf.AddTx(tx, "peerA") + if err == nil { + t.Fatal("expected error from commitment mismatch") + } + if len(dropped) != 1 || dropped[0] != "peerA" { + t.Fatalf("only peerA should have been dropped, got: %v", dropped) + } + if buf.HasTx(tx.Hash()) { + t.Fatal("tx should not be buffered") + } +} diff --git a/core/txpool/blobpool/limbo.go b/core/txpool/blobpool/limbo.go index 90cd9d4a9d..8b711f744b 100644 --- a/core/txpool/blobpool/limbo.go +++ b/core/txpool/blobpool/limbo.go @@ -33,7 +33,7 @@ import ( type limboBlob struct { TxHash common.Hash // Owner transaction's hash to support resurrecting reorged txs Block uint64 // Block in which the blob transaction was included - Tx *pooledBlobTx + Tx *PooledBlobTx } // limbo is a light, indexed database to temporarily store recently included @@ -146,7 +146,7 @@ func (l *limbo) finalize(final *types.Header) { // push stores a new blob transaction into the limbo, waiting until finality for // it to be automatically evicted. -func (l *limbo) push(tx *pooledBlobTx, block uint64) error { +func (l *limbo) push(tx *PooledBlobTx, block uint64) error { // If the blobs are already tracked by the limbo, consider it a programming // error. There's not much to do against it, but be loud. if _, ok := l.index[tx.Transaction.Hash()]; ok { @@ -163,7 +163,7 @@ func (l *limbo) push(tx *pooledBlobTx, block uint64) error { // pull retrieves a previously pushed set of blobs back from the limbo, removing // it at the same time. This method should be used when a previously included blob // transaction gets reorged out. -func (l *limbo) pull(tx common.Hash) (*pooledBlobTx, error) { +func (l *limbo) pull(tx common.Hash) (*PooledBlobTx, error) { // If the blobs are not tracked by the limbo, there's not much to do. This // can happen for example if a blob transaction is mined without pushing it // into the network first. @@ -240,7 +240,7 @@ func (l *limbo) getAndDrop(id uint64) (*limboBlob, error) { // setAndIndex assembles a limbo blob database entry and stores it, also updating // the in-memory indices. -func (l *limbo) setAndIndex(tx *pooledBlobTx, block uint64) error { +func (l *limbo) setAndIndex(tx *PooledBlobTx, block uint64) error { txhash := tx.Transaction.Hash() item := &limboBlob{ TxHash: txhash, diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index f928a99517..b917ad5c9f 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -76,11 +76,17 @@ type cellWithSeq struct { cells *types.CustodyBitmap } +// PeerCellDelivery holds cells delivered by a single peer. +type PeerCellDelivery struct { + Cells []kzg4844.Cell // blob-major order as received + Indices []uint64 // custody indices provided by this peer +} + type fetchStatus struct { - fetching *types.CustodyBitmap // To avoid fetching cells which had already been fetched / currently being fetched - fetched []uint64 // Custody indices that have been fetched (per-blob, same for all blobs) - blobCells [][]kzg4844.Cell // Per-blob cell accumulator, indexed by blob - blobCount int // Number of blobs in this tx (set on first delivery) + fetching *types.CustodyBitmap // To avoid fetching cells which had already been fetched / currently being fetched + fetched []uint64 // Custody indices that have been fetched (per-blob, same for all blobs) + deliveries map[string]*PeerCellDelivery // Per-peer cell deliveries + blobCount int // Number of blobs in this tx (set on first delivery) } // BlobFetcher is responsible for managing type 3 transactions based on peer announcements. @@ -124,7 +130,7 @@ type BlobFetcher struct { // Callbacks hasPayload func(common.Hash) bool - addPayload func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error //todo: peer disconnection is strange here + addCells func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error dropPeer func(string) @@ -136,7 +142,7 @@ type BlobFetcher struct { func NewBlobFetcher( hasPayload func(common.Hash) bool, - addPayload func([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error, + addCells func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error, fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error, dropPeer func(string), custody *types.CustodyBitmap, rand random) *BlobFetcher { return &BlobFetcher{ @@ -154,7 +160,7 @@ func NewBlobFetcher( requests: make(map[string][]*cellRequest), alternates: make(map[common.Hash]map[string]*types.CustodyBitmap), hasPayload: hasPayload, - addPayload: addPayload, + addCells: addCells, fetchPayloads: fetchPayloads, dropPeer: dropPeer, custody: custody, @@ -470,21 +476,18 @@ func (f *BlobFetcher) loop() { // Unexpected hash, ignore continue } - // delivery.cells[i] contains cells for all blobs - // in blob-major order: [blob0_cell0, ..., blob0_cellN, blob1_cell0, ...]. indices := delivery.cellBitmap.Indices() cellsPerBlob := len(indices) if cellsPerBlob > 0 { status := f.fetches[hash] blobCount := len(delivery.cells[i]) / cellsPerBlob - // Initialize per-blob accumulators on first delivery if status.blobCount == 0 { status.blobCount = blobCount - status.blobCells = make([][]kzg4844.Cell, blobCount) + status.deliveries = make(map[string]*PeerCellDelivery) } - for b := 0; b < blobCount; b++ { - offset := b * cellsPerBlob - status.blobCells[b] = append(status.blobCells[b], delivery.cells[i][offset:offset+cellsPerBlob]...) + status.deliveries[delivery.origin] = &PeerCellDelivery{ + Cells: delivery.cells[i], + Indices: indices, } status.fetched = append(status.fetched, indices...) } @@ -515,28 +518,10 @@ func (f *BlobFetcher) loop() { if completed { blobFetcherFetchTime.Update(int64(time.Duration(f.clock.Now() - request.time))) - fetchStatus := f.fetches[hash] + status := f.fetches[hash] + collectedCustody := types.NewCustodyBitmap(status.fetched) + f.addCells(hash, status.deliveries, &collectedCustody) - // Sort each blob's cells by ascending custody index. - // RecoverBlobs expects cells[k] to correspond to custodyIndices[k], - // and custodyIndices come from CustodyBitmap.Indices() which is always sorted. - perm := make([]int, len(fetchStatus.fetched)) - for i := range perm { - perm[i] = i - } - slices.SortFunc(perm, func(a, b int) int { - return int(fetchStatus.fetched[a]) - int(fetchStatus.fetched[b]) - }) - var assembled []kzg4844.Cell - for _, blobCells := range fetchStatus.blobCells { - for _, p := range perm { - assembled = append(assembled, blobCells[p]) - } - } - collectedCustody := types.NewCustodyBitmap(fetchStatus.fetched) - f.addPayload([]common.Hash{hash}, [][]kzg4844.Cell{assembled}, &collectedCustody) - - // remove announces from other peers for peer, txset := range f.announces { delete(txset, hash) if len(txset) == 0 { diff --git a/eth/fetcher/blob_fetcher_test.go b/eth/fetcher/blob_fetcher_test.go index de11b30da3..589957bf23 100644 --- a/eth/fetcher/blob_fetcher_test.go +++ b/eth/fetcher/blob_fetcher_test.go @@ -17,7 +17,6 @@ package fetcher import ( - "fmt" "slices" "testing" @@ -149,8 +148,8 @@ func TestBlobFetcherFullFetch(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) + func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, @@ -238,8 +237,8 @@ func TestBlobFetcherPartialFetch(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) + func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, @@ -331,8 +330,8 @@ func TestBlobFetcherFullDelivery(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) + func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, @@ -377,8 +376,8 @@ func TestBlobFetcherPartialDelivery(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) + func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, @@ -511,8 +510,8 @@ func TestBlobFetcherAvailabilityTimeout(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) + func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, @@ -551,8 +550,8 @@ func TestBlobFetcherPeerDrop(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) + func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, @@ -626,8 +625,8 @@ func TestBlobFetcherFetchTimeout(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, _ [][]kzg4844.Cell, _ *types.CustodyBitmap) []error { - return make([]error, len(txs)) + func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, @@ -1012,40 +1011,21 @@ func TestMultiBlobDeliveryVerification(t *testing.T) { init: func() *BlobFetcher { return NewBlobFetcher( func(common.Hash) bool { return false }, - func(txs []common.Hash, cells [][]kzg4844.Cell, cst *types.CustodyBitmap) []error { - // Verify delivered cells pass KZG cell proof verification - // Debug: compare with expected cells - expectedCells := selectMultiBlobCells(sidecar, custody) - for ci, c := range cells { - if len(c) != len(expectedCells) { - verifyErr = fmt.Errorf("cell count mismatch: have %d, want %d", len(c), len(expectedCells)) - return make([]error, len(txs)) - } - for j := range c { - if c[j] != expectedCells[j] { - verifyErr = fmt.Errorf("tx %d cell %d mismatch (custody=%v)", ci, j, cst.Indices()) - return make([]error, len(txs)) - } - } - } - for _, c := range cells { - cs := &types.BlobTxCellSidecar{ - Version: sidecar.Version, - Cells: c, - Commitments: sidecar.Commitments, - Proofs: sidecar.Proofs, - Custody: *cst, - } - indices := cs.Custody.Indices() + func(hash common.Hash, deliveries map[string]*PeerCellDelivery, cst *types.CustodyBitmap) error { + // Verify each peer's delivered cells pass KZG cell proof verification + for _, d := range deliveries { var cellProofs []kzg4844.Proof - for blobIdx := range len(cs.Commitments) { - for _, proofIdx := range indices { - cellProofs = append(cellProofs, cs.Proofs[blobIdx*kzg4844.CellProofsPerBlob+int(proofIdx)]) + for blobIdx := 0; blobIdx < len(sidecar.Commitments); blobIdx++ { + for _, idx := range d.Indices { + cellProofs = append(cellProofs, sidecar.Proofs[blobIdx*kzg4844.CellProofsPerBlob+int(idx)]) } } - verifyErr = kzg4844.VerifyCells(cs.Cells, cs.Commitments, cellProofs, indices) + verifyErr = kzg4844.VerifyCells(d.Cells, sidecar.Commitments, cellProofs, d.Indices) + if verifyErr != nil { + return verifyErr + } } - return make([]error, len(txs)) + return nil }, func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, func(string) {}, diff --git a/eth/fetcher/tx_fetcher.go b/eth/fetcher/tx_fetcher.go index f4e9271302..6e2ea24426 100644 --- a/eth/fetcher/tx_fetcher.go +++ b/eth/fetcher/tx_fetcher.go @@ -180,10 +180,10 @@ type TxFetcher struct { alternates map[common.Hash]map[string]struct{} // In-flight transaction alternate origins if retrieval fails // Callbacks - validateMeta func(common.Hash, byte) error // Validate a tx metadata based on the local txpool - addTxs func([]*types.Transaction) []error // Insert a batch of transactions into local txpool - fetchTxs func(string, []common.Hash) error // Retrieves a set of txs from a remote peer - dropPeer func(string) // Drops a peer in case of announcement violation + validateMeta func(common.Hash, byte) error // Validate a tx metadata based on the local txpool + addTxs func(string, []*types.Transaction) []error // Insert a batch of transactions into local txpool + fetchTxs func(string, []common.Hash) error // Retrieves a set of txs from a remote peer + dropPeer func(string) // Drops a peer in case of announcement violation step chan struct{} // Notification channel when the fetcher loop iterates clock mclock.Clock // Monotonic clock or simulated clock for tests @@ -194,7 +194,7 @@ type TxFetcher struct { // NewTxFetcher creates a transaction fetcher to retrieve transaction // based on hash announcements. // Chain can be nil to disable on-chain checks. -func NewTxFetcher(chain *core.BlockChain, validateMeta func(common.Hash, byte) error, addTxs func([]*types.Transaction) []error, fetchTxs func(string, []common.Hash) error, dropPeer func(string)) *TxFetcher { +func NewTxFetcher(chain *core.BlockChain, validateMeta func(common.Hash, byte) error, addTxs func(string, []*types.Transaction) []error, fetchTxs func(string, []common.Hash) error, dropPeer func(string)) *TxFetcher { return NewTxFetcherForTests(chain, validateMeta, addTxs, fetchTxs, dropPeer, mclock.System{}, time.Now, nil) } @@ -202,7 +202,7 @@ func NewTxFetcher(chain *core.BlockChain, validateMeta func(common.Hash, byte) e // a simulated version and the internal randomness with a deterministic one. // Chain can be nil to disable on-chain checks. func NewTxFetcherForTests( - chain *core.BlockChain, validateMeta func(common.Hash, byte) error, addTxs func([]*types.Transaction) []error, fetchTxs func(string, []common.Hash) error, dropPeer func(string), + chain *core.BlockChain, validateMeta func(common.Hash, byte) error, addTxs func(string, []*types.Transaction) []error, fetchTxs func(string, []common.Hash) error, dropPeer func(string), clock mclock.Clock, realTime func() time.Time, rand *mrand.Rand) *TxFetcher { return &TxFetcher{ notify: make(chan *txAnnounce), @@ -352,7 +352,7 @@ func (f *TxFetcher) Enqueue(peer string, txs []*types.Transaction, direct bool) ) batch := txs[i:end] - for j, err := range f.addTxs(batch) { + for j, err := range f.addTxs(peer, batch) { // Track the transaction hash if the price is too low for us. // Avoid re-request this transaction when we receive another // announcement. diff --git a/eth/fetcher/tx_fetcher_test.go b/eth/fetcher/tx_fetcher_test.go index de8413142a..4e8ea14000 100644 --- a/eth/fetcher/tx_fetcher_test.go +++ b/eth/fetcher/tx_fetcher_test.go @@ -93,7 +93,7 @@ func newTestTxFetcher() *TxFetcher { return NewTxFetcher( nil, func(common.Hash, byte) error { return nil }, - func(txs []*types.Transaction) []error { + func(_ string, txs []*types.Transaction) []error { return make([]error, len(txs)) }, func(string, []common.Hash) error { return nil }, @@ -1172,7 +1172,7 @@ func TestTransactionFetcherUnderpricedDedup(t *testing.T) { testTransactionFetcherParallel(t, txFetcherTest{ init: func() *TxFetcher { f := newTestTxFetcher() - f.addTxs = func(txs []*types.Transaction) []error { + f.addTxs = func(_ string, txs []*types.Transaction) []error { errs := make([]error, len(txs)) for i := 0; i < len(errs); i++ { if i%3 == 0 { @@ -1270,7 +1270,7 @@ func TestTransactionFetcherUnderpricedDoSProtection(t *testing.T) { testTransactionFetcher(t, txFetcherTest{ init: func() *TxFetcher { f := newTestTxFetcher() - f.addTxs = func(txs []*types.Transaction) []error { + f.addTxs = func(_ string, txs []*types.Transaction) []error { errs := make([]error, len(txs)) for i := 0; i < len(errs); i++ { errs[i] = txpool.ErrUnderpriced @@ -1787,7 +1787,7 @@ func TestTransactionProtocolViolation(t *testing.T) { testTransactionFetcherParallel(t, txFetcherTest{ init: func() *TxFetcher { f := newTestTxFetcher() - f.addTxs = func(txs []*types.Transaction) []error { + f.addTxs = func(_ string, txs []*types.Transaction) []error { var errs []error for range txs { errs = append(errs, txpool.ErrKZGVerificationError) @@ -2194,7 +2194,7 @@ func TestTransactionForgotten(t *testing.T) { fetcher := NewTxFetcherForTests( nil, func(common.Hash, byte) error { return nil }, - func(txs []*types.Transaction) []error { + func(_ string, txs []*types.Transaction) []error { errs := make([]error, len(txs)) for i := 0; i < len(errs); i++ { errs[i] = txpool.ErrUnderpriced diff --git a/eth/handler.go b/eth/handler.go index c584e7a78b..34ce49102d 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -32,6 +32,7 @@ import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/blobpool" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/eth/downloader" @@ -104,8 +105,8 @@ type blobPool interface { Has(hash common.Hash) bool GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) HasPayload(hash common.Hash) bool - AddPayload([]common.Hash, [][]kzg4844.Cell, *types.CustodyBitmap) []error GetCustody(hash common.Hash) *types.CustodyBitmap + AddPooledTx(pooledTx *blobpool.PooledBlobTx) error } // handlerConfig is the collection of initialization parameters to create a full @@ -138,6 +139,7 @@ type handler struct { downloader *downloader.Downloader txFetcher *fetcher.TxFetcher blobFetcher *fetcher.BlobFetcher + blobBuffer *blobpool.BlobBuffer peers *peerSet txBroadcastKey [16]byte @@ -192,11 +194,34 @@ func newHandler(config *handlerConfig) (*handler, error) { } return p.RequestTxs(hashes) } - addTxs := func(txs []*types.Transaction) []error { - return h.txpool.Add(txs, false) + // Construct the blob buffer for assembling blob txs from separate tx and cell deliveries + h.blobBuffer = blobpool.NewBlobBuffer(h.blobpool.AddPooledTx, h.removePeer) + + addTxs := func(peer string, txs []*types.Transaction) []error { + errs := make([]error, len(txs)) + p := h.peers.peer(peer) + isETH71 := p != nil && p.Version() >= eth.ETH71 + + var poolTxs []*types.Transaction + var index []int + for i, tx := range txs { + if isETH71 && tx.Type() == types.BlobTxType { + errs[i] = h.blobBuffer.AddTx(tx, peer) + } else { + poolTxs = append(poolTxs, tx) + index = append(index, i) + } + } + if len(poolTxs) > 0 { + poolErrs := h.txpool.Add(poolTxs, false) + for j, idx := range index { + errs[idx] = poolErrs[j] + } + } + return errs } validateMeta := func(tx common.Hash, kind byte) error { - if h.txpool.Has(tx) { + if h.txpool.Has(tx) || h.blobBuffer.HasTx(tx) { return txpool.ErrAlreadyKnown } if !h.txpool.FilterType(kind) { @@ -214,7 +239,17 @@ func newHandler(config *handlerConfig) (*handler, error) { } return p.RequestPayload(hashes, cells) } - h.blobFetcher = fetcher.NewBlobFetcher(h.blobpool.HasPayload, h.blobpool.AddPayload, fetchPayloads, h.removePeer, &config.Custody, nil) + hasPayload := func(hash common.Hash) bool { + return h.blobpool.HasPayload(hash) || h.blobBuffer.HasCells(hash) + } + addCells := func(hash common.Hash, deliveries map[string]*fetcher.PeerCellDelivery, custody *types.CustodyBitmap) error { + converted := make(map[string]*blobpool.PeerDelivery, len(deliveries)) + for peer, d := range deliveries { + converted[peer] = &blobpool.PeerDelivery{Cells: d.Cells, Indices: d.Indices} + } + return h.blobBuffer.AddCells(hash, converted, custody) + } + h.blobFetcher = fetcher.NewBlobFetcher(hasPayload, addCells, fetchPayloads, h.removePeer, &config.Custody, nil) return h, nil } diff --git a/eth/handler_test.go b/eth/handler_test.go index 0bb994cb13..75b8d12668 100644 --- a/eth/handler_test.go +++ b/eth/handler_test.go @@ -30,6 +30,7 @@ import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/blobpool" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto/kzg4844" @@ -222,13 +223,12 @@ func (p *testTxPool) AddCells(hash common.Hash, cells []kzg4844.Cell, mask types p.custody[hash] = mask } -func (p *testTxPool) AddPayload(txs []common.Hash, cells [][]kzg4844.Cell, custody *types.CustodyBitmap) []error { +func (p *testTxPool) AddPooledTx(pooledTx *blobpool.PooledBlobTx) error { p.lock.Lock() defer p.lock.Unlock() - - for i, tx := range txs { - p.cellPool[tx] = cells[i] - } + hash := pooledTx.Transaction.Hash() + p.cellPool[hash] = pooledTx.Sidecar.Cells + p.txPool[hash] = pooledTx.Transaction return nil } diff --git a/tests/fuzzers/txfetcher/txfetcher_fuzzer.go b/tests/fuzzers/txfetcher/txfetcher_fuzzer.go index 872573d40f..1e15b991fa 100644 --- a/tests/fuzzers/txfetcher/txfetcher_fuzzer.go +++ b/tests/fuzzers/txfetcher/txfetcher_fuzzer.go @@ -80,7 +80,7 @@ func fuzz(input []byte) int { f := fetcher.NewTxFetcherForTests( nil, func(common.Hash, byte) error { return nil }, - func(txs []*types.Transaction) []error { + func(_ string, txs []*types.Transaction) []error { return make([]error, len(txs)) }, func(string, []common.Hash) error { return nil }, From f6657afa3bd36db493dc555d3e4158b09921e088 Mon Sep 17 00:00:00 2001 From: healthykim Date: Sat, 11 Apr 2026 18:22:27 +0900 Subject: [PATCH 13/24] rename protocol version from eth71 to eth72 --- core/txpool/blobpool/blobpool.go | 4 ++-- core/txpool/blobpool/buffer.go | 2 +- core/txpool/blobpool/buffer_test.go | 2 +- core/txpool/blobpool/lookup.go | 2 +- core/txpool/subpool.go | 4 ++-- eth/handler.go | 4 ++-- eth/handler_eth.go | 4 ++-- eth/protocols/eth/broadcast.go | 2 +- eth/protocols/eth/handler.go | 6 +++--- eth/protocols/eth/handlers.go | 2 +- eth/protocols/eth/peer.go | 2 +- eth/protocols/eth/protocol.go | 8 ++++---- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 2f44991d21..644f43913b 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -130,7 +130,7 @@ type blobTxMeta struct { 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 - sizeWithoutBlob uint64 // RLP-encoded size of transaction without blob data (for ETH/71) + sizeWithoutBlob uint64 // RLP-encoded size of transaction without blob data (for ETH/72) custody *types.CustodyBitmap @@ -1500,7 +1500,7 @@ func (p *BlobPool) getRLP(hash common.Hash) []byte { // - (1) Store them separately on disk, tracking both IDs. // - (2) Keep transactions in memory and store cells on disk. // -// However, this approach does not fit well with eth71 peers, since blobs +// However, this approach does not fit well with eth72 peers, since blobs // must be included in that case. It may require decoding and re-encoding, // as well as double disk I/O each time. func (p *BlobPool) Get(hash common.Hash, includeBlob bool) *types.Transaction { diff --git a/core/txpool/blobpool/buffer.go b/core/txpool/blobpool/buffer.go index 30dba84ae8..e9695849ea 100644 --- a/core/txpool/blobpool/buffer.go +++ b/core/txpool/blobpool/buffer.go @@ -67,7 +67,7 @@ func NewBlobBuffer(addToPool func(*PooledBlobTx) error, dropPeer func(string)) * } } -// AddTx buffers a blob transaction (without blobs) from an ETH/71 peer. +// AddTx buffers a blob transaction (without blobs) from an ETH/72 peer. // If cells are already buffered, verification and pool insertion are attempted. func (b *BlobBuffer) AddTx(tx *types.Transaction, peer string) error { b.evict() diff --git a/core/txpool/blobpool/buffer_test.go b/core/txpool/blobpool/buffer_test.go index 74680c530a..1a75742212 100644 --- a/core/txpool/blobpool/buffer_test.go +++ b/core/txpool/blobpool/buffer_test.go @@ -13,7 +13,7 @@ import ( ) // makeV1Tx creates a V1 blob transaction with cell proofs, then strips blobs -// (simulating what ETH/71 peers send). +// (simulating what ETH/72 peers send). func makeV1Tx(t *testing.T, nonce uint64, blobCount int, blobOffset int, key *ecdsa.PrivateKey) *types.Transaction { t.Helper() tx := makeMultiBlobTx(nonce, 1, 1, 1, blobCount, blobOffset, key, types.BlobSidecarVersion1) diff --git a/core/txpool/blobpool/lookup.go b/core/txpool/blobpool/lookup.go index 39cb2c69b9..cbbeedde1b 100644 --- a/core/txpool/blobpool/lookup.go +++ b/core/txpool/blobpool/lookup.go @@ -24,7 +24,7 @@ import ( type txMetadata struct { id uint64 // the billy id of transction size uint64 // the RLP encoded size of transaction (blobs are included) - sizeWithoutBlob uint64 // the RLP encoded size without blob data (for ETH/71 announcements) + sizeWithoutBlob uint64 // the RLP encoded size without blob data (for ETH/72 announcements) custody types.CustodyBitmap } diff --git a/core/txpool/subpool.go b/core/txpool/subpool.go index 5f35f0a44a..b8f1384651 100644 --- a/core/txpool/subpool.go +++ b/core/txpool/subpool.go @@ -88,7 +88,7 @@ type PendingFilter struct { type TxMetadata struct { Type uint8 // The type of the transaction Size uint64 // The length of the 'rlp encoding' of a transaction (including blobs) - SizeWithoutBlob uint64 // The length without blob data (for ETH/71 announcements) + SizeWithoutBlob uint64 // The length without blob data (for ETH/72 announcements) } // SubPool represents a specialized transaction pool that lives on its own (e.g. @@ -133,7 +133,7 @@ type SubPool interface { Get(hash common.Hash, includeBlob bool) *types.Transaction // GetRLP returns a RLP-encoded transaction if it is contained in the pool. - // If includeBlob is false, blob data is stripped from blob transactions (ETH/71). + // If includeBlob is false, blob data is stripped from blob transactions (ETH/72). GetRLP(hash common.Hash, includeBlob bool) []byte // GetMetadata returns the transaction type and transaction size with the diff --git a/eth/handler.go b/eth/handler.go index 34ce49102d..2fb479a0ff 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -200,12 +200,12 @@ func newHandler(config *handlerConfig) (*handler, error) { addTxs := func(peer string, txs []*types.Transaction) []error { errs := make([]error, len(txs)) p := h.peers.peer(peer) - isETH71 := p != nil && p.Version() >= eth.ETH71 + isETH72 := p != nil && p.Version() >= eth.ETH72 var poolTxs []*types.Transaction var index []int for i, tx := range txs { - if isETH71 && tx.Type() == types.BlobTxType { + if isETH72 && tx.Type() == types.BlobTxType { errs[i] = h.blobBuffer.AddTx(tx, peer) } else { poolTxs = append(poolTxs, tx) diff --git a/eth/handler_eth.go b/eth/handler_eth.go index c0052d44cc..077d5de389 100644 --- a/eth/handler_eth.go +++ b/eth/handler_eth.go @@ -113,9 +113,9 @@ func handleTransactions(peer *eth.Peer, list []*types.Transaction, directBroadca // If we receive any blob transactions missing sidecars, or with // sidecars that don't correspond to the versioned hashes reported // in the header, disconnect from the sending peer. - if peer.Version() >= eth.ETH71 { + if peer.Version() >= eth.ETH72 { if tx.BlobTxSidecar() != nil && len(tx.BlobTxSidecar().Blobs) != 0 { - return fmt.Errorf("not allowed to respond with full-blob transaction under eth71") + return fmt.Errorf("not allowed to respond with full-blob transaction under eth72") } } else { if tx.BlobTxSidecar() == nil { diff --git a/eth/protocols/eth/broadcast.go b/eth/protocols/eth/broadcast.go index a1fa419f40..2c71cdec08 100644 --- a/eth/protocols/eth/broadcast.go +++ b/eth/protocols/eth/broadcast.go @@ -133,7 +133,7 @@ func (p *Peer) announceTransactions() { } pending = append(pending, queue[count]) pendingTypes = append(pendingTypes, meta.Type) - if p.version >= ETH71 && meta.SizeWithoutBlob > 0 { + if p.version >= ETH72 && meta.SizeWithoutBlob > 0 { pendingSizes = append(pendingSizes, uint32(meta.SizeWithoutBlob)) } else { pendingSizes = append(pendingSizes, uint32(meta.Size)) diff --git a/eth/protocols/eth/handler.go b/eth/protocols/eth/handler.go index 41a58abc08..ff90d6e328 100644 --- a/eth/protocols/eth/handler.go +++ b/eth/protocols/eth/handler.go @@ -194,7 +194,7 @@ var eth69 = map[uint64]msgHandler{ BlockRangeUpdateMsg: handleBlockRangeUpdate, } -var eth71 = map[uint64]msgHandler{ +var eth72 = map[uint64]msgHandler{ TransactionsMsg: handleTransactions, NewPooledTransactionHashesMsg: handleNewPooledTransactionHashes71, GetBlockHeadersMsg: handleGetBlockHeaders, @@ -227,8 +227,8 @@ func handleMessage(backend Backend, peer *Peer) error { switch peer.version { case ETH69: handlers = eth69 - case ETH71: - handlers = eth71 + case ETH72: + handlers = eth72 default: return fmt.Errorf("unknown eth protocol version: %v", peer.version) } diff --git a/eth/protocols/eth/handlers.go b/eth/protocols/eth/handlers.go index 81afd9aaee..d349449044 100644 --- a/eth/protocols/eth/handlers.go +++ b/eth/protocols/eth/handlers.go @@ -523,7 +523,7 @@ func handleGetPooledTransactions(backend Backend, msg Decoder, peer *Peer) error if err := msg.Decode(&query); err != nil { return err } - hashes, txs := answerGetPooledTransactions(backend, query.GetPooledTransactionsRequest, peer.version < ETH71) + hashes, txs := answerGetPooledTransactions(backend, query.GetPooledTransactionsRequest, peer.version < ETH72) return peer.ReplyPooledTransactionsRLP(query.RequestId, hashes, txs) } diff --git a/eth/protocols/eth/peer.go b/eth/protocols/eth/peer.go index b6f88ed4d2..b63b43459d 100644 --- a/eth/protocols/eth/peer.go +++ b/eth/protocols/eth/peer.go @@ -172,7 +172,7 @@ func (p *Peer) AsyncSendTransactions(hashes []common.Hash) { func (p *Peer) sendPooledTransactionHashes(hashes []common.Hash, types []byte, sizes []uint32, cells types.CustodyBitmap) error { // Mark all the transactions as known, but ensure we don't overflow our limits p.knownTxs.Add(hashes...) - if p.version >= ETH71 { + if p.version >= ETH72 { return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket71{Types: types, Sizes: sizes, Hashes: hashes, Mask: cells}) } return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket70{Types: types, Sizes: sizes, Hashes: hashes}) diff --git a/eth/protocols/eth/protocol.go b/eth/protocols/eth/protocol.go index d1aafa34f1..9ef1928b8f 100644 --- a/eth/protocols/eth/protocol.go +++ b/eth/protocols/eth/protocol.go @@ -31,7 +31,7 @@ import ( // Constants to match up protocol versions and messages const ( ETH69 = 69 - ETH71 = 71 + ETH72 = 72 ) // ProtocolName is the official short name of the `eth` protocol used during @@ -40,11 +40,11 @@ const ProtocolName = "eth" // ProtocolVersions are the supported versions of the `eth` protocol (first // is primary). -var ProtocolVersions = []uint{ETH71, ETH69} +var ProtocolVersions = []uint{ETH72, ETH69} // protocolLengths are the number of implemented message corresponding to // different protocol versions. -var protocolLengths = map[uint]uint64{ETH69: 18, ETH71: 20} +var protocolLengths = map[uint]uint64{ETH69: 18, ETH72: 20} // maxMessageSize is the maximum cap on the size of a protocol message. const maxMessageSize = 10 * 1024 * 1024 @@ -241,7 +241,7 @@ type NewPooledTransactionHashesPacket70 struct { Hashes []common.Hash } -// NewPooledTransactionHashesPacket71 represents a transaction announcement packet on eth/71 +// NewPooledTransactionHashesPacket71 represents a transaction announcement packet on ETH/72 // with an additional custody bitmap field for cell-based blob data availability. type NewPooledTransactionHashesPacket71 struct { Types []byte From d2b36d4c0b1d68e9de084456c0a9c9ae176a3536 Mon Sep 17 00:00:00 2001 From: healthykim Date: Mon, 13 Apr 2026 22:47:18 +0200 Subject: [PATCH 14/24] add eager mode --- eth/fetcher/blob_fetcher.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index b917ad5c9f..7ceb019dcd 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -277,7 +277,8 @@ func (f *BlobFetcher) loop() { } else { randomValue = f.rand.Intn(100) } - if randomValue < fetchProbability { + // For eager mode, always fetch immediately + if randomValue < fetchProbability || f.custody.OneCount() >= kzg4844.DataPerBlob { f.full[hash] = struct{}{} } else { f.partial[hash] = struct{}{} From f7e46cc505ab1f2c7e6464d817fbd0b642e7968e Mon Sep 17 00:00:00 2001 From: healthykim Date: Mon, 13 Apr 2026 22:47:53 +0200 Subject: [PATCH 15/24] fix hive test errors --- cmd/devp2p/internal/ethtest/conn.go | 10 ++++- cmd/devp2p/internal/ethtest/protocol.go | 2 +- cmd/devp2p/internal/ethtest/suite.go | 52 +++++++++++++--------- cmd/devp2p/internal/ethtest/transaction.go | 4 +- eth/handler_eth.go | 20 +++------ eth/handler_eth_test.go | 2 +- eth/protocols/eth/handlers.go | 4 +- eth/protocols/eth/peer.go | 4 +- eth/protocols/eth/protocol.go | 14 +++--- 9 files changed, 61 insertions(+), 51 deletions(-) diff --git a/cmd/devp2p/internal/ethtest/conn.go b/cmd/devp2p/internal/ethtest/conn.go index 8d1998c921..66924d8f54 100644 --- a/cmd/devp2p/internal/ethtest/conn.go +++ b/cmd/devp2p/internal/ethtest/conn.go @@ -66,9 +66,11 @@ func (s *Suite) dialAs(key *ecdsa.PrivateKey) (*Conn, error) { return nil, err } conn.caps = []p2p.Cap{ + {Name: "eth", Version: 72}, + {Name: "eth", Version: 70}, {Name: "eth", Version: 69}, } - conn.ourHighestProtoVersion = 69 + conn.ourHighestProtoVersion = 72 return &conn, nil } @@ -167,11 +169,15 @@ func (c *Conn) ReadEth() (any, error) { case eth.TransactionsMsg: msg = new(eth.TransactionsPacket) case eth.NewPooledTransactionHashesMsg: - msg = new(eth.NewPooledTransactionHashesPacket70) + msg = new(eth.NewPooledTransactionHashesPacket72) case eth.GetPooledTransactionsMsg: msg = new(eth.GetPooledTransactionsPacket) case eth.PooledTransactionsMsg: msg = new(eth.PooledTransactionsPacket) + case eth.GetCellsMsg: + msg = new(eth.GetCellsRequest) + case eth.CellsMsg: + msg = new(eth.CellsResponse) default: panic(fmt.Sprintf("unhandled eth msg code %d", code)) } diff --git a/cmd/devp2p/internal/ethtest/protocol.go b/cmd/devp2p/internal/ethtest/protocol.go index a21d1ca7a1..9da3142f5b 100644 --- a/cmd/devp2p/internal/ethtest/protocol.go +++ b/cmd/devp2p/internal/ethtest/protocol.go @@ -32,7 +32,7 @@ const ( // Unexported devp2p protocol lengths from p2p package. const ( baseProtoLen = 16 - ethProtoLen = 18 + ethProtoLen = 20 snapProtoLen = 8 ) diff --git a/cmd/devp2p/internal/ethtest/suite.go b/cmd/devp2p/internal/ethtest/suite.go index b21fedb96d..286872ff63 100644 --- a/cmd/devp2p/internal/ethtest/suite.go +++ b/cmd/devp2p/internal/ethtest/suite.go @@ -865,7 +865,7 @@ the transactions using a GetPooledTransactions request.`) } // Send announcement. - ann := eth.NewPooledTransactionHashesPacket70{Types: txTypes, Sizes: sizes, Hashes: hashes} + ann := eth.NewPooledTransactionHashesPacket72{Types: txTypes, Sizes: sizes, Hashes: hashes} err = conn.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann) if err != nil { t.Fatalf("failed to write to connection: %v", err) @@ -883,7 +883,7 @@ the transactions using a GetPooledTransactions request.`) t.Fatalf("unexpected number of txs requested: wanted %d, got %d", len(hashes), len(msg.GetPooledTransactionsRequest)) } return - case *eth.NewPooledTransactionHashesPacket70: + case *eth.NewPooledTransactionHashesPacket72: continue case *eth.TransactionsPacket: continue @@ -902,11 +902,11 @@ func makeSidecar(data ...byte) *types.BlobTxSidecar { for i := range blobs { blobs[i][0] = data[i] c, _ := kzg4844.BlobToCommitment(&blobs[i]) - p, _ := kzg4844.ComputeBlobProof(&blobs[i], c) + cellProofs, _ := kzg4844.ComputeCellProofs(&blobs[i]) commitments = append(commitments, c) - proofs = append(proofs, p) + proofs = append(proofs, cellProofs...) } - return types.NewBlobTxSidecar(types.BlobSidecarVersion0, blobs, commitments, proofs) + return types.NewBlobTxSidecar(types.BlobSidecarVersion1, blobs, commitments, proofs) } func (s *Suite) makeBlobTxs(count, blobs int, discriminator byte) (txs types.Transactions) { @@ -949,24 +949,26 @@ func (s *Suite) TestBlobViolations(t *utesting.T) { t2 = s.makeBlobTxs(2, 3, 0x2) ) for _, test := range []struct { - ann eth.NewPooledTransactionHashesPacket70 + ann eth.NewPooledTransactionHashesPacket72 resp eth.PooledTransactionsResponse }{ // Invalid tx size. { - ann: eth.NewPooledTransactionHashesPacket70{ + ann: eth.NewPooledTransactionHashesPacket72{ Types: []byte{types.BlobTxType, types.BlobTxType}, Sizes: []uint32{uint32(t1[0].Size()), uint32(t1[1].Size() + 10)}, Hashes: []common.Hash{t1[0].Hash(), t1[1].Hash()}, + Mask: *types.CustodyBitmapAll, }, resp: eth.PooledTransactionsResponse(t1), }, // Wrong tx type. { - ann: eth.NewPooledTransactionHashesPacket70{ + ann: eth.NewPooledTransactionHashesPacket72{ Types: []byte{types.DynamicFeeTxType, types.BlobTxType}, Sizes: []uint32{uint32(t2[0].Size()), uint32(t2[1].Size())}, Hashes: []common.Hash{t2[0].Hash(), t2[1].Hash()}, + Mask: *types.CustodyBitmapAll, }, resp: eth.PooledTransactionsResponse(t2), }, @@ -994,15 +996,21 @@ func (s *Suite) TestBlobViolations(t *utesting.T) { if code, _, err := conn.Read(); err != nil { t.Fatalf("expected disconnect on blob violation, got err: %v", err) } else if code != discMsg { - if code == protoOffset(ethProto)+eth.NewPooledTransactionHashesMsg { - // sometimes we'll get a blob transaction hashes announcement before the disconnect - // because blob transactions are scheduled to be fetched right away. - if code, _, err = conn.Read(); err != nil { - t.Fatalf("expected disconnect on blob violation, got err on second read: %v", err) + for { + code, _, err := conn.Read() + if err != nil { + t.Fatalf("expected disconnect on blob violation, got err: %v", err) + } + if code == discMsg { + break + } + switch code { + case protoOffset(ethProto) + eth.NewPooledTransactionHashesMsg, + protoOffset(ethProto) + eth.GetCellsMsg: + continue + default: + t.Fatalf("expected disconnect on blob violation, got msg code: %d", code) } - } - if code != discMsg { - t.Fatalf("expected disconnect on blob violation, got msg code: %d", code) } } conn.Close() @@ -1021,14 +1029,14 @@ func mangleSidecar(tx *types.Transaction) *types.Transaction { func (s *Suite) TestBlobTxWithoutSidecar(t *utesting.T) { t.Log(`This test checks that a blob transaction first advertised/transmitted without blobs will result in the sending peer being disconnected, and the full transaction should be successfully retrieved from another peer.`) - tx := s.makeBlobTxs(1, 2, 42)[0] + tx := s.makeBlobTxs(1, 2, 42)[0].WithoutBlob() badTx := tx.WithoutBlobTxSidecar() s.testBadBlobTx(t, tx, badTx) } func (s *Suite) TestBlobTxWithMismatchedSidecar(t *utesting.T) { t.Log(`This test checks that a blob transaction first advertised/transmitted without blobs, whose commitment don't correspond to the blob_versioned_hashes in the transaction, will result in the sending peer being disconnected, and the full transaction should be successfully retrieved from another peer.`) - tx := s.makeBlobTxs(1, 2, 43)[0] + tx := s.makeBlobTxs(1, 2, 43)[0].WithoutBlob() badTx := mangleSidecar(tx) s.testBadBlobTx(t, tx, badTx) } @@ -1092,10 +1100,11 @@ func (s *Suite) testBadBlobTx(t *utesting.T, tx *types.Transaction, badTx *types return } - ann := eth.NewPooledTransactionHashesPacket70{ + ann := eth.NewPooledTransactionHashesPacket72{ Types: []byte{types.BlobTxType}, Sizes: []uint32{uint32(badTx.Size())}, Hashes: []common.Hash{badTx.Hash()}, + Mask: *types.CustodyBitmapAll, } if err := conn.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann); err != nil { @@ -1143,14 +1152,15 @@ func (s *Suite) testBadBlobTx(t *utesting.T, tx *types.Transaction, badTx *types return } - ann := eth.NewPooledTransactionHashesPacket70{ + ann := eth.NewPooledTransactionHashesPacket72{ Types: []byte{types.BlobTxType}, Sizes: []uint32{uint32(tx.Size())}, Hashes: []common.Hash{tx.Hash()}, + Mask: *types.CustodyBitmapAll, } if err := conn.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann); err != nil { - errc <- fmt.Errorf("sending announcement failed: %v", err) + errc <- fmt.Errorf("sending first announcement failed: %v", err) return } diff --git a/cmd/devp2p/internal/ethtest/transaction.go b/cmd/devp2p/internal/ethtest/transaction.go index 6cebfcea70..f9c2e304f3 100644 --- a/cmd/devp2p/internal/ethtest/transaction.go +++ b/cmd/devp2p/internal/ethtest/transaction.go @@ -74,7 +74,7 @@ func (s *Suite) sendTxs(t *utesting.T, txs []*types.Transaction) error { for _, tx := range txs { got[tx.Hash()] = true } - case *eth.NewPooledTransactionHashesPacket70: + case *eth.NewPooledTransactionHashesPacket72: for _, hash := range msg.Hashes { got[hash] = true } @@ -160,7 +160,7 @@ func (s *Suite) sendInvalidTxs(t *utesting.T, txs []*types.Transaction) error { return fmt.Errorf("received bad tx: %s", tx.Hash()) } } - case *eth.NewPooledTransactionHashesPacket70: + case *eth.NewPooledTransactionHashesPacket72: for _, hash := range msg.Hashes { if _, ok := invalids[hash]; ok { return fmt.Errorf("received bad tx: %s", hash) diff --git a/eth/handler_eth.go b/eth/handler_eth.go index 077d5de389..37573c3cbd 100644 --- a/eth/handler_eth.go +++ b/eth/handler_eth.go @@ -59,7 +59,7 @@ func (h *ethHandler) AcceptTxs() bool { func (h *ethHandler) Handle(peer *eth.Peer, packet eth.Packet) error { // Consume any broadcasts and announces, forwarding the rest to the downloader switch packet := packet.(type) { - case *eth.NewPooledTransactionHashesPacket71: + case *eth.NewPooledTransactionHashesPacket72: hashes, err := h.txFetcher.Notify(peer.ID(), packet.Types, packet.Sizes, packet.Hashes) if err != nil { return err @@ -69,7 +69,7 @@ func (h *ethHandler) Handle(peer *eth.Peer, packet eth.Packet) error { } return nil - case *eth.NewPooledTransactionHashesPacket70: + case *eth.NewPooledTransactionHashesPacket71: _, err := h.txFetcher.Notify(peer.ID(), packet.Types, packet.Sizes, packet.Hashes) return err @@ -113,17 +113,11 @@ func handleTransactions(peer *eth.Peer, list []*types.Transaction, directBroadca // If we receive any blob transactions missing sidecars, or with // sidecars that don't correspond to the versioned hashes reported // in the header, disconnect from the sending peer. - if peer.Version() >= eth.ETH72 { - if tx.BlobTxSidecar() != nil && len(tx.BlobTxSidecar().Blobs) != 0 { - return fmt.Errorf("not allowed to respond with full-blob transaction under eth72") - } - } else { - if tx.BlobTxSidecar() == nil { - return errors.New("received sidecar-less blob transaction") - } - if err := tx.BlobTxSidecar().ValidateBlobCommitmentHashes(tx.BlobHashes()); err != nil { - return err - } + if tx.BlobTxSidecar() == nil { + return errors.New("received sidecar-less blob transaction") + } + if err := tx.BlobTxSidecar().ValidateBlobCommitmentHashes(tx.BlobHashes()); err != nil { + return err } } } diff --git a/eth/handler_eth_test.go b/eth/handler_eth_test.go index 3f219ded56..9d93de37e5 100644 --- a/eth/handler_eth_test.go +++ b/eth/handler_eth_test.go @@ -51,7 +51,7 @@ func (h *testEthHandler) PeerInfo(enode.ID) interface{} { panic("not used func (h *testEthHandler) Handle(peer *eth.Peer, packet eth.Packet) error { switch packet := packet.(type) { - case *eth.NewPooledTransactionHashesPacket70: + case *eth.NewPooledTransactionHashesPacket71: h.txAnnounces.Send(packet.Hashes) return nil diff --git a/eth/protocols/eth/handlers.go b/eth/protocols/eth/handlers.go index d349449044..0af04f87a0 100644 --- a/eth/protocols/eth/handlers.go +++ b/eth/protocols/eth/handlers.go @@ -483,7 +483,7 @@ func handleNewPooledTransactionHashes(backend Backend, msg Decoder, peer *Peer) if !backend.AcceptTxs() { return nil } - ann := new(NewPooledTransactionHashesPacket70) + ann := new(NewPooledTransactionHashesPacket71) if err := msg.Decode(ann); err != nil { return err } @@ -503,7 +503,7 @@ func handleNewPooledTransactionHashes71(backend Backend, msg Decoder, peer *Peer if !backend.AcceptTxs() { return nil } - ann := new(NewPooledTransactionHashesPacket71) + ann := new(NewPooledTransactionHashesPacket72) if err := msg.Decode(ann); err != nil { return err } diff --git a/eth/protocols/eth/peer.go b/eth/protocols/eth/peer.go index b63b43459d..183bf324c2 100644 --- a/eth/protocols/eth/peer.go +++ b/eth/protocols/eth/peer.go @@ -173,9 +173,9 @@ func (p *Peer) sendPooledTransactionHashes(hashes []common.Hash, types []byte, s // Mark all the transactions as known, but ensure we don't overflow our limits p.knownTxs.Add(hashes...) if p.version >= ETH72 { - return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket71{Types: types, Sizes: sizes, Hashes: hashes, Mask: cells}) + return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket72{Types: types, Sizes: sizes, Hashes: hashes, Mask: cells}) } - return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket70{Types: types, Sizes: sizes, Hashes: hashes}) + return p2p.Send(p.rw, NewPooledTransactionHashesMsg, NewPooledTransactionHashesPacket71{Types: types, Sizes: sizes, Hashes: hashes}) } // AsyncSendPooledTransactionHashes queues a list of transactions hashes to eventually diff --git a/eth/protocols/eth/protocol.go b/eth/protocols/eth/protocol.go index 9ef1928b8f..7fd590efd1 100644 --- a/eth/protocols/eth/protocol.go +++ b/eth/protocols/eth/protocol.go @@ -234,16 +234,16 @@ type ReceiptsPacket struct { // ReceiptsRLPResponse is used for receipts, when we already have it encoded type ReceiptsRLPResponse []rlp.RawValue -// NewPooledTransactionHashesPacket70 represents a transaction announcement packet on eth/69. -type NewPooledTransactionHashesPacket70 struct { +// NewPooledTransactionHashesPacket71 represents a transaction announcement packet on eth/69. +type NewPooledTransactionHashesPacket71 struct { Types []byte Sizes []uint32 Hashes []common.Hash } -// NewPooledTransactionHashesPacket71 represents a transaction announcement packet on ETH/72 +// NewPooledTransactionHashesPacket72 represents a transaction announcement packet on ETH/72 // with an additional custody bitmap field for cell-based blob data availability. -type NewPooledTransactionHashesPacket71 struct { +type NewPooledTransactionHashesPacket72 struct { Types []byte Sizes []uint32 Hashes []common.Hash @@ -329,12 +329,12 @@ func (*GetBlockBodiesRequest) Kind() byte { return GetBlockBodiesMsg } func (*BlockBodiesResponse) Name() string { return "BlockBodies" } func (*BlockBodiesResponse) Kind() byte { return BlockBodiesMsg } -func (*NewPooledTransactionHashesPacket70) Name() string { return "NewPooledTransactionHashes" } -func (*NewPooledTransactionHashesPacket70) Kind() byte { return NewPooledTransactionHashesMsg } - func (*NewPooledTransactionHashesPacket71) Name() string { return "NewPooledTransactionHashes" } func (*NewPooledTransactionHashesPacket71) Kind() byte { return NewPooledTransactionHashesMsg } +func (*NewPooledTransactionHashesPacket72) Name() string { return "NewPooledTransactionHashes" } +func (*NewPooledTransactionHashesPacket72) Kind() byte { return NewPooledTransactionHashesMsg } + func (*GetPooledTransactionsRequest) Name() string { return "GetPooledTransactions" } func (*GetPooledTransactionsRequest) Kind() byte { return GetPooledTransactionsMsg } From 6bbcbe6a65f58726e1a5fc6120939cb14a754817 Mon Sep 17 00:00:00 2001 From: healthykim Date: Tue, 14 Apr 2026 15:28:24 +0200 Subject: [PATCH 16/24] add hive tests --- cmd/devp2p/internal/ethtest/conn.go | 4 +- cmd/devp2p/internal/ethtest/suite.go | 300 +++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 2 deletions(-) diff --git a/cmd/devp2p/internal/ethtest/conn.go b/cmd/devp2p/internal/ethtest/conn.go index 66924d8f54..7f5f6a5dd1 100644 --- a/cmd/devp2p/internal/ethtest/conn.go +++ b/cmd/devp2p/internal/ethtest/conn.go @@ -175,9 +175,9 @@ func (c *Conn) ReadEth() (any, error) { case eth.PooledTransactionsMsg: msg = new(eth.PooledTransactionsPacket) case eth.GetCellsMsg: - msg = new(eth.GetCellsRequest) + msg = new(eth.GetCellsRequestPacket) case eth.CellsMsg: - msg = new(eth.CellsResponse) + msg = new(eth.CellsPacket) default: panic(fmt.Sprintf("unhandled eth msg code %d", code)) } diff --git a/cmd/devp2p/internal/ethtest/suite.go b/cmd/devp2p/internal/ethtest/suite.go index 286872ff63..db6a10d3d4 100644 --- a/cmd/devp2p/internal/ethtest/suite.go +++ b/cmd/devp2p/internal/ethtest/suite.go @@ -21,6 +21,7 @@ import ( "crypto/rand" "errors" "fmt" + "os" "reflect" "sync" "time" @@ -91,6 +92,10 @@ func (s *Suite) EthTests() []utesting.Test { {Name: "BlobViolations", Fn: s.TestBlobViolations}, {Name: "TestBlobTxWithoutSidecar", Fn: s.TestBlobTxWithoutSidecar}, {Name: "TestBlobTxWithMismatchedSidecar", Fn: s.TestBlobTxWithMismatchedSidecar}, + // test eth/72 blob txs + {Name: "BlobTxAvailabilityFailure", Fn: s.TestBlobTxAvailabilityFailure}, + {Name: "GetCells", Fn: s.TestGetCells}, + {Name: "BlobTxWithInvalidCells", Fn: s.TestBlobTxWithInvalidCells}, } } @@ -1210,3 +1215,298 @@ func (s *Suite) testBadBlobTx(t *utesting.T, tx *types.Transaction, badTx *types t.Fatalf("%v", err) } } + +func (s *Suite) TestBlobTxAvailabilityFailure(t *utesting.T) { + t.Log(`This test announces 4 blob txs from a single peer. With fetchProbability 0.15, +there will be at least one partial fetch (1-0.15^4). When only 1 peer announced availability, +partial fetch GetCells should never arrive. Any GetCells that does arrive must be a full fetch.`) + + if err := s.engine.sendForkchoiceUpdated(); err != nil { + t.Fatalf("send fcu failed: %v", err) + } + + txs := s.makeBlobTxs(4, 4, 0x30) + + conn, err := s.dial() + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn.Close() + if err := conn.peer(s.chain, nil); err != nil { + t.Fatalf("peering failed: %v", err) + } + + // Announce all 4 txs from a single peer. + hashes := make([]common.Hash, len(txs)) + txTypes := make([]byte, len(txs)) + sizes := make([]uint32, len(txs)) + for i, tx := range txs { + hashes[i] = tx.Hash() + txTypes[i] = types.BlobTxType + sizes[i] = uint32(tx.WithoutBlob().Size()) + } + ann := eth.NewPooledTransactionHashesPacket72{ + Types: txTypes, + Sizes: sizes, + Hashes: hashes, + Mask: *types.CustodyBitmapAll, + } + if err := conn.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann); err != nil { + t.Fatalf("announce failed: %v", err) + } + + // Read messages for a short period. Any GetCells that arrives must be + // a full fetch request (mask >= DataPerBlob), not a partial fetch. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + for { + select { + case <-ctx.Done(): + return + default: + } + msg, err := conn.ReadEth() + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + return // timeout, test passed + } + t.Fatalf("unexpected error: %v", err) + } + switch req := msg.(type) { + case *eth.GetCellsRequestPacket: + if req.Mask.OneCount() < kzg4844.DataPerBlob { + t.Fatalf("received partial GetCells request with only %d cells from single peer announcement", req.Mask.OneCount()) + } + case *eth.GetPooledTransactionsPacket: + var txsWithoutBlob []*types.Transaction + for _, h := range req.GetPooledTransactionsRequest { + for _, tx := range txs { + if tx.Hash() == h { + txsWithoutBlob = append(txsWithoutBlob, tx.WithoutBlob()) + } + } + } + encTxs, _ := rlp.EncodeToRawList(txsWithoutBlob) + conn.Write(ethProto, eth.PooledTransactionsMsg, eth.PooledTransactionsPacket{ + RequestId: req.RequestId, + List: encTxs, + }) + } + } +} + +// buildCells extracts cells at mask indices from the original tx's blobs +func buildCells(sidecar *types.BlobTxSidecar, mask types.CustodyBitmap) []kzg4844.Cell { + allCells, _ := kzg4844.ComputeCells(sidecar.Blobs) + indices := mask.Indices() + result := make([]kzg4844.Cell, 0, len(sidecar.Blobs)*len(indices)) + for b := 0; b < len(sidecar.Blobs); b++ { + for _, idx := range indices { + result = append(result, allCells[b*kzg4844.CellsPerBlob+int(idx)]) + } + } + return result +} + +// readAnyFrom waits for a message of type T on any of the given conns +// and returns the packet and the conn it came from. +func readAnyFrom[T any](conns ...*Conn) (*T, *Conn, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + type result struct { + pkt *T + c *Conn + } + ch := make(chan result, len(conns)) + errCh := make(chan error, len(conns)) + + for _, c := range conns { + go func(c *Conn) { + pkt, err := readUntil[T](ctx, c) + if err != nil { + if !errors.Is(err, context.Canceled) { + errCh <- err + } + return + } + ch <- result{pkt, c} + }(c) + } + select { + case r := <-ch: + return r.pkt, r.c, nil + case err := <-errCh: + return nil, nil, err + } +} + +func (s *Suite) TestGetCells(t *utesting.T) { + t.Log(`This test checks that blob tx announcements trigger GetCells requests, +and that providing valid cells causes the tx to enter the pool.`) + + if err := s.engine.sendForkchoiceUpdated(); err != nil { + t.Fatalf("send fcu failed: %v", err) + } + + tx := s.makeBlobTxs(1, 1, 0x31)[0] + sidecar := tx.BlobTxSidecar() + tx = tx.WithoutBlob() + + // Two peers ensure GetCells arrives regardless of full/partial fetch path. + conn1, err := s.dial() + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn1.Close() + if err := conn1.peer(s.chain, nil); err != nil { + t.Fatalf("peering failed: %v", err) + } + + conn2, err := s.dial() + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn2.Close() + if err := conn2.peer(s.chain, nil); err != nil { + t.Fatalf("peering failed: %v", err) + } + + ann := eth.NewPooledTransactionHashesPacket72{ + Types: []byte{types.BlobTxType}, + Sizes: []uint32{uint32(tx.Size())}, + Hashes: []common.Hash{tx.Hash()}, + Mask: *types.CustodyBitmapAll, + } + if err := conn1.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann); err != nil { + t.Fatalf("conn1 announce failed: %v", err) + } + if err := conn2.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann); err != nil { + t.Fatalf("conn2 announce failed: %v", err) + } + + // Wait for GetPooledTransactions on either conn, respond with tx (without blobs). + pooledReq, pc, err := readAnyFrom[eth.GetPooledTransactionsPacket](conn1, conn2) + if err != nil { + t.Fatalf("failed to read GetPooledTransactions: %v", err) + } + encTxs, _ := rlp.EncodeToRawList([]*types.Transaction{tx}) + resp := eth.PooledTransactionsPacket{RequestId: pooledReq.RequestId, List: encTxs} + if err := pc.Write(ethProto, eth.PooledTransactionsMsg, resp); err != nil { + t.Fatalf("writing pooled tx response failed: %v", err) + } + + // Wait for GetCells request on either conn. + cellsReq, cc, err := readAnyFrom[eth.GetCellsRequestPacket](conn1, conn2) + if err != nil { + t.Fatalf("failed to read GetCells: %v", err) + } + if len(cellsReq.Hashes) == 0 || cellsReq.Hashes[0] != tx.Hash() { + t.Fatalf("GetCells for wrong hash: %v", cellsReq.Hashes) + } + + // Respond with valid cells matching the requested mask. + cells := buildCells(sidecar, cellsReq.Mask) + cellsResp := eth.CellsPacket{ + RequestId: cellsReq.RequestId, + CellsResponse: eth.CellsResponse{ + Hashes: []common.Hash{tx.Hash()}, + Cells: [][]kzg4844.Cell{cells}, + Mask: cellsReq.Mask, + }, + } + if err := cc.Write(ethProto, eth.CellsMsg, cellsResp); err != nil { + t.Fatalf("writing cells response failed: %v", err) + } + + // Either peer should not be disconnected after providing valid data. + if readUntilDisconnect(cc) { + t.Fatalf("unexpected disconnect on cells-providing peer") + } +} + +func (s *Suite) TestBlobTxWithInvalidCells(t *utesting.T) { + t.Log(`This test checks that a peer responding to GetCells with invalid cells is disconnected, +while the other peer is not.`) + + if err := s.engine.sendForkchoiceUpdated(); err != nil { + t.Fatalf("send fcu failed: %v", err) + } + + tx := s.makeBlobTxs(1, 1, 0x32)[0].WithoutBlob() + + conn1, err := s.dial() + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn1.Close() + if err := conn1.peer(s.chain, nil); err != nil { + t.Fatalf("peering failed: %v", err) + } + + conn2, err := s.dial() + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn2.Close() + if err := conn2.peer(s.chain, nil); err != nil { + t.Fatalf("peering failed: %v", err) + } + + ann := eth.NewPooledTransactionHashesPacket72{ + Types: []byte{types.BlobTxType}, + Sizes: []uint32{uint32(tx.Size())}, + Hashes: []common.Hash{tx.Hash()}, + Mask: *types.CustodyBitmapAll, + } + if err := conn1.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann); err != nil { + t.Fatalf("conn1 announce failed: %v", err) + } + if err := conn2.Write(ethProto, eth.NewPooledTransactionHashesMsg, ann); err != nil { + t.Fatalf("conn2 announce failed: %v", err) + } + + pooledReq, pc, err := readAnyFrom[eth.GetPooledTransactionsPacket](conn1, conn2) + if err != nil { + t.Fatalf("failed to read GetPooledTransactions: %v", err) + } + encTxs, _ := rlp.EncodeToRawList([]*types.Transaction{tx}) + if err := pc.Write(ethProto, eth.PooledTransactionsMsg, + eth.PooledTransactionsPacket{RequestId: pooledReq.RequestId, List: encTxs}); err != nil { + t.Fatalf("writing pooled tx response failed: %v", err) + } + + cellsReq, cc, err := readAnyFrom[eth.GetCellsRequestPacket](conn1, conn2) + if err != nil { + t.Fatalf("failed to read GetCells: %v", err) + } + + // Respond with corrupted cells (all zero bytes). + blobCount := len(tx.BlobTxSidecar().Blobs) + corrupted := make([]kzg4844.Cell, blobCount*cellsReq.Mask.OneCount()) + badResp := eth.CellsPacket{ + RequestId: cellsReq.RequestId, + CellsResponse: eth.CellsResponse{ + Hashes: []common.Hash{tx.Hash()}, + Cells: [][]kzg4844.Cell{corrupted}, + Mask: cellsReq.Mask, + }, + } + if err := cc.Write(ethProto, eth.CellsMsg, badResp); err != nil { + t.Fatalf("writing bad cells response failed: %v", err) + } + + // The peer that sent corrupted cells must be disconnected. + if !readUntilDisconnect(cc) { + t.Fatalf("expected peer to be disconnected after invalid cells") + } + + // The innocent peer must stay connected. + otherConn := conn1 + if cc == conn1 { + otherConn = conn2 + } + if readUntilDisconnect(otherConn) { + t.Fatalf("innocent peer should not be disconnected") + } +} From b2c50675ca836bfba4f50b669ae6d836327aee8c Mon Sep 17 00:00:00 2001 From: healthykim Date: Tue, 21 Apr 2026 15:53:52 +0200 Subject: [PATCH 17/24] core/txpool, eth: remove GetCells --- core/txpool/blobpool/blobpool.go | 46 ++++++---------------- core/txpool/blobpool/blobpool_test.go | 41 ++++++++++---------- core/txpool/blobpool/lookup.go | 11 ++++++ eth/handler.go | 3 +- eth/handler_test.go | 56 ++++++++++++++++++++------- eth/protocols/eth/handler.go | 6 ++- eth/protocols/eth/handlers.go | 35 ++++++++++++++--- 7 files changed, 121 insertions(+), 77 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 644f43913b..1359158817 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1671,6 +1671,17 @@ func (p *BlobPool) GetBlobs(vhashes []common.Hash, version byte) ([]*kzg4844.Blo return blobs, commitments, proofs, nil } +// GetBlobHashes returns the blob versioned hashes for a given transaction hash. +func (p *BlobPool) GetBlobHashes(txHash common.Hash) []common.Hash { + p.lock.RLock() + defer p.lock.RUnlock() + vhashes, ok := p.lookup.blobHashesOfTx(txHash) + if !ok { + return nil + } + return vhashes +} + // GetBlobCells returns cells for the given versioned blob hashes, // filtered by the requested cell indices(mask). // Each entry in the result corresponds to one vhash. Nil entries mean the blob @@ -2436,38 +2447,3 @@ func (p *BlobPool) GetCustody(hash common.Hash) *types.CustodyBitmap { } return nil } - -// GetCells returns the cells matching the given custody bitmap for a transaction. -func (p *BlobPool) GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) { - p.lock.RLock() - defer p.lock.RUnlock() - id, ok := p.lookup.storeidOfTx(hash) - if !ok { - return nil, errors.New("requested cells don't exist") - } - data, err := p.store.Get(id) - if err != nil { - return nil, errors.New("tracked blob transaction missing from store") - } - // Decode the blob transaction - var pooledTx PooledBlobTx - if err := rlp.DecodeBytes(data, &pooledTx); err != nil { - return nil, errors.New("blobs corrupted for traced transaction") - } - tx := pooledTx.Transaction - sidecar := pooledTx.Sidecar - // Return cells in blob-major order: [blob0_cell0, blob0_cell1, ..., blob1_cell0, ...] - cellsPerBlob := sidecar.Custody.OneCount() - cells := make([]kzg4844.Cell, 0, mask.OneCount()*len(tx.BlobHashes())) - for blobIdx := 0; blobIdx < len(tx.BlobHashes()); blobIdx++ { - for cellIdx, custodyIdx := range sidecar.Custody.Indices() { - if mask.IsSet(custodyIdx) { - cells = append(cells, sidecar.Cells[blobIdx*cellsPerBlob+cellIdx]) - } - } - } - if len(cells) != mask.OneCount()*len(tx.BlobHashes()) { - return nil, fmt.Errorf("not enough cells: tx %s, needed %d, have %d", tx.Hash(), len(tx.BlobHashes())*mask.OneCount(), len(cells)) - } - return cells, nil -} diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go index 35e9958bf3..cf072384c2 100644 --- a/core/txpool/blobpool/blobpool_test.go +++ b/core/txpool/blobpool/blobpool_test.go @@ -2227,33 +2227,32 @@ func TestGetCells(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cells, err := pool.GetCells(tt.hash, tt.mask) - - if err != nil && !tt.shouldFail { - t.Errorf("expected to success, got %v", err) + vhashes := pool.GetBlobHashes(tt.hash) + if tt.shouldFail { + if vhashes != nil { + t.Errorf("expected nil vhashes for non-existent tx") + } + return } - if err == nil && tt.shouldFail { - t.Errorf("expected to fail, got %v", err) + if vhashes == nil { + t.Fatalf("expected vhashes, got nil") } - - if len(cells) != tt.expectedLen { - t.Errorf("expected %d cells, got %d", tt.expectedLen, len(cells)) + blobCells, _, err := pool.GetBlobCells(vhashes, tt.mask) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - - if tt.expectedLen > 0 && tt.expectedLen%3 == 0 { - blobCount := 3 - cellsPerBlob := tt.expectedLen / blobCount - - for blobIdx := 0; blobIdx < blobCount; blobIdx++ { - startIdx := blobIdx * cellsPerBlob - endIdx := startIdx + cellsPerBlob - - if endIdx > len(cells) { - t.Errorf("blob %d: expected cells up to index %d, but only have %d cells", - blobIdx, endIdx-1, len(cells)) + // Count total non-nil cells across all blobs + totalCells := 0 + for _, bc := range blobCells { + for _, c := range bc { + if c != nil { + totalCells++ } } } + if totalCells != tt.expectedLen { + t.Errorf("expected %d cells, got %d", tt.expectedLen, totalCells) + } }) } } diff --git a/core/txpool/blobpool/lookup.go b/core/txpool/blobpool/lookup.go index cbbeedde1b..78ddcf3089 100644 --- a/core/txpool/blobpool/lookup.go +++ b/core/txpool/blobpool/lookup.go @@ -26,6 +26,7 @@ type txMetadata struct { size uint64 // the RLP encoded size of transaction (blobs are included) sizeWithoutBlob uint64 // the RLP encoded size without blob data (for ETH/72 announcements) custody types.CustodyBitmap + vhashes []common.Hash // blob versioned hashes for the transaction } // lookup maps blob versioned hashes to transaction hashes that include them, @@ -59,6 +60,15 @@ func (l *lookup) storeidOfTx(txhash common.Hash) (uint64, bool) { return meta.id, true } +// blobHashesOfTx returns the blob versioned hashes for a transaction. +func (l *lookup) blobHashesOfTx(txhash common.Hash) ([]common.Hash, bool) { + meta, ok := l.txIndex[txhash] + if !ok { + return nil, false + } + return meta.vhashes, true +} + // storeidOfBlob returns the datastore storage item id of a blob. func (l *lookup) storeidOfBlob(vhash common.Hash) (uint64, bool) { // If the blob is unknown, return a miss @@ -98,6 +108,7 @@ func (l *lookup) track(tx *blobTxMeta) { size: tx.size, sizeWithoutBlob: tx.sizeWithoutBlob, custody: *tx.custody, + vhashes: tx.vhashes, } } diff --git a/eth/handler.go b/eth/handler.go index 2fb479a0ff..539c8a7c6d 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -103,7 +103,8 @@ type txPool interface { // support cell-based blob data availability. type blobPool interface { Has(hash common.Hash) bool - GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) + GetBlobHashes(hash common.Hash) []common.Hash + GetBlobCells(vhashes []common.Hash, mask types.CustodyBitmap) ([][]*kzg4844.Cell, [][]*kzg4844.Proof, error) HasPayload(hash common.Hash) bool GetCustody(hash common.Hash) *types.CustodyBitmap AddPooledTx(pooledTx *blobpool.PooledBlobTx) error diff --git a/eth/handler_test.go b/eth/handler_test.go index 75b8d12668..ca1a978c62 100644 --- a/eth/handler_test.go +++ b/eth/handler_test.go @@ -17,7 +17,6 @@ package eth import ( - "errors" "maps" "math/big" "math/rand" @@ -181,28 +180,59 @@ func (p *testTxPool) Pending(filter txpool.PendingFilter) (map[common.Address][] func (p *testTxPool) SubscribeTransactions(ch chan<- core.NewTxsEvent, reorgs bool) event.Subscription { return p.txFeed.Subscribe(ch) } -func (p *testTxPool) GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) { +func (p *testTxPool) GetBlobHashes(hash common.Hash) []common.Hash { p.lock.RLock() defer p.lock.RUnlock() - _, exists := p.txPool[hash] + tx, exists := p.txPool[hash] if !exists { - return nil, errors.New("Requested tx does not exist") + return nil } + return tx.BlobHashes() +} - var cells []kzg4844.Cell +func (p *testTxPool) GetBlobCells(vhashes []common.Hash, mask types.CustodyBitmap) ([][]*kzg4844.Cell, [][]*kzg4844.Proof, error) { + p.lock.RLock() + defer p.lock.RUnlock() - if cells, exists = p.cellPool[hash]; !exists { - return nil, errors.New("Requested cells do not exist") - } + requestedIndices := mask.Indices() + cells := make([][]*kzg4844.Cell, len(vhashes)) + proofs := make([][]*kzg4844.Proof, len(vhashes)) - result := make([]kzg4844.Cell, 0, mask.OneCount()) - for _, idx := range mask.Indices() { - if int(idx) < len(cells) { - result = append(result, cells[idx]) + for i, vhash := range vhashes { + // Find the tx containing this versioned hash + var foundTx *types.Transaction + var blobIdx int + for _, tx := range p.txPool { + for j, bh := range tx.BlobHashes() { + if bh == vhash { + foundTx = tx + blobIdx = j + break + } + } + if foundTx != nil { + break + } } + if foundTx == nil { + continue + } + txCells, ok := p.cellPool[foundTx.Hash()] + if !ok { + continue + } + _ = blobIdx // cells in the mock are stored flat by cell index + blobCells := make([]*kzg4844.Cell, len(requestedIndices)) + for j, idx := range requestedIndices { + if int(idx) < len(txCells) { + cell := txCells[idx] + blobCells[j] = &cell + } + } + cells[i] = blobCells } - return result, nil + return cells, proofs, nil } func (p *testTxPool) GetCustody(hash common.Hash) *types.CustodyBitmap { diff --git a/eth/protocols/eth/handler.go b/eth/protocols/eth/handler.go index ff90d6e328..26f4ddfda9 100644 --- a/eth/protocols/eth/handler.go +++ b/eth/protocols/eth/handler.go @@ -89,8 +89,10 @@ type Backend interface { // BlobPool defines the methods needed by the protocol handler to serve cell requests. type BlobPool interface { - // GetCells retrieves cells for a given transaction hash filtered by the custody bitmap. - GetCells(hash common.Hash, mask types.CustodyBitmap) ([]kzg4844.Cell, error) + // GetBlobHashes returns the blob versioned hashes for a given transaction hash. + GetBlobHashes(hash common.Hash) []common.Hash + // GetBlobCells retrieves cells and proofs for given versioned blob hashes filtered by the custody bitmap. + GetBlobCells(vhashes []common.Hash, mask types.CustodyBitmap) ([][]*kzg4844.Cell, [][]*kzg4844.Proof, error) // GetCustody returns the custody bitmap for a given transaction hash. GetCustody(hash common.Hash) *types.CustodyBitmap // Has returns whether the blob pool contains a transaction with the given hash. diff --git a/eth/protocols/eth/handlers.go b/eth/protocols/eth/handlers.go index 0af04f87a0..68bf5ae787 100644 --- a/eth/protocols/eth/handlers.go +++ b/eth/protocols/eth/handlers.go @@ -623,14 +623,39 @@ func answerGetCells(backend Backend, query GetCellsRequest) ([]common.Hash, [][] if cellCounts >= maxCells { break } - cell, _ := backend.BlobPool().GetCells(hash, query.Mask) - if len(cell) == 0 { - // skip this tx + // Look up the blob versioned hashes for this transaction + vhashes := backend.BlobPool().GetBlobHashes(hash) + if len(vhashes) == 0 { + continue + } + blobCells, _, _ := backend.BlobPool().GetBlobCells(vhashes, query.Mask) + + // Flatten per-blob cells into a single slice. If any blob has a nil + // entry (unavailable cell), skip the entire transaction. + var flat []kzg4844.Cell + skip := false + for _, bc := range blobCells { + if bc == nil { + skip = true + break + } + for _, c := range bc { + if c == nil { + skip = true + break + } + flat = append(flat, *c) + } + if skip { + break + } + } + if skip || len(flat) == 0 { continue } hashes = append(hashes, hash) - cells = append(cells, cell) - cellCounts += len(cell) + cells = append(cells, flat) + cellCounts += len(flat) } return hashes, cells, query.Mask } From 4983e4372cc6b6291dfd9a671ccdc35fcbb5630c Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 23 Apr 2026 12:25:32 +0200 Subject: [PATCH 18/24] fix engine api error --- core/types/custody_bitmap.go | 38 +++++++++++++++++++++++++++++++++++- eth/catalyst/api.go | 14 ++++--------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/core/types/custody_bitmap.go b/core/types/custody_bitmap.go index 8e52e093d1..d3624bb914 100644 --- a/core/types/custody_bitmap.go +++ b/core/types/custody_bitmap.go @@ -17,15 +17,51 @@ package types import ( + "fmt" + "math/big" "math/bits" "math/rand" "github.com/ethereum/go-ethereum/crypto/kzg4844" ) -// `CustodyBitmap` is a bitmap to represent which custody index to store (little endian) +// CustodyBitmap is a bitmap to represent which custody index to store (little endian). +// It is serialized as a hex-encoded uint128 quantity (e.g. "0x89") for JSON-RPC. type CustodyBitmap [16]byte +// MarshalText implements encoding.TextMarshaler. +// Encodes the bitmap as a hex-encoded uint128 quantity. +func (b CustodyBitmap) MarshalText() ([]byte, error) { + var be [16]byte + for i := range b { + be[15-i] = b[i] + } + v := new(big.Int).SetBytes(be[:]) + return []byte("0x" + v.Text(16)), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. +// Parses a hex-encoded uint128 quantity into the bitmap. +func (b *CustodyBitmap) UnmarshalText(input []byte) error { + s := string(input) + if len(s) < 2 || (s[:2] != "0x" && s[:2] != "0X") { + return fmt.Errorf("custody bitmap: missing 0x prefix") + } + v, ok := new(big.Int).SetString(s[2:], 16) + if !ok { + return fmt.Errorf("custody bitmap: invalid hex %q", s) + } + if v.BitLen() > 128 { + return fmt.Errorf("custody bitmap: value exceeds 128 bits") + } + *b = CustodyBitmap{} + beBytes := v.Bytes() // big-endian + for i, byt := range beBytes { + b[len(beBytes)-1-i] = byt + } + return nil +} + var ( CustodyBitmapAll = func() *CustodyBitmap { var result CustodyBitmap diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index 6aaa287eac..b980920493 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -662,7 +662,7 @@ func (api *ConsensusAPI) getBlobs(hashes []common.Hash, v2 bool) ([]*engine.Blob // GetBlobsV4 returns cell-level blob data from the transaction pool. // V4 returns only the requested cells as specified by the indices_bitarray. -func (api *ConsensusAPI) GetBlobsV4(hashes []common.Hash, indicesBitarray hexutil.Bytes) ([]*engine.BlobCellsAndProofsV1, error) { +func (api *ConsensusAPI) GetBlobsV4(hashes []common.Hash, indicesBitarray types.CustodyBitmap) ([]*engine.BlobCellsAndProofsV1, error) { head := api.eth.BlockChain().CurrentHeader() // Sparse blobpool is not necessarily coupled with the Amsterdam fork and // can technically be supported after the Osaka fork @@ -673,12 +673,7 @@ func (api *ConsensusAPI) GetBlobsV4(hashes []common.Hash, indicesBitarray hexuti if len(hashes) > 128 { return nil, engine.TooLargeRequest.With(fmt.Errorf("requested blob count too large: %v", len(hashes))) } - if len(indicesBitarray) != 16 { - return nil, engine.InvalidParams.With(fmt.Errorf("indices_bitarray must be 16 bytes, got %d", len(indicesBitarray))) - } - var mask types.CustodyBitmap - copy(mask[:], indicesBitarray) - cells, proofs, err := api.eth.BlobTxPool().GetBlobCells(hashes, mask) + cells, proofs, err := api.eth.BlobTxPool().GetBlobCells(hashes, indicesBitarray) if err != nil { return nil, engine.InvalidParams.With(err) } @@ -1223,9 +1218,8 @@ func (api *ConsensusAPI) getBodiesByRange(start, count hexutil.Uint64) ([]*engin return bodies, nil } -func (api *ConsensusAPI) BlobCustodyUpdatedV1(custodyColumns []uint64) { - bitmap := types.NewCustodyBitmap(custodyColumns) - api.eth.BlobFetcher().UpdateCustody(bitmap) +func (api *ConsensusAPI) BlobCustodyUpdatedV1(indicesBitarray types.CustodyBitmap) { + api.eth.BlobFetcher().UpdateCustody(indicesBitarray) } func getBody(block *types.Block) *engine.ExecutionPayloadBody { From e41650a24b5cb2a3d178a596c937f90bb280d840 Mon Sep 17 00:00:00 2001 From: healthykim Date: Wed, 13 May 2026 17:35:02 +0200 Subject: [PATCH 19/24] eth/protocols/eth: fix protocol message length --- eth/protocols/eth/protocol.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eth/protocols/eth/protocol.go b/eth/protocols/eth/protocol.go index 7fd590efd1..f32288d96f 100644 --- a/eth/protocols/eth/protocol.go +++ b/eth/protocols/eth/protocol.go @@ -44,7 +44,7 @@ var ProtocolVersions = []uint{ETH72, ETH69} // protocolLengths are the number of implemented message corresponding to // different protocol versions. -var protocolLengths = map[uint]uint64{ETH69: 18, ETH72: 20} +var protocolLengths = map[uint]uint64{ETH69: 18, ETH72: 22} // maxMessageSize is the maximum cap on the size of a protocol message. const maxMessageSize = 10 * 1024 * 1024 @@ -67,8 +67,8 @@ const ( GetReceiptsMsg = 0x0f ReceiptsMsg = 0x10 BlockRangeUpdateMsg = 0x11 - GetCellsMsg = 0x12 - CellsMsg = 0x13 + GetCellsMsg = 0x14 + CellsMsg = 0x15 ) var ( From a7896c90dbabb9bea5480a0f6ebad0445d8a9135 Mon Sep 17 00:00:00 2001 From: healthykim Date: Wed, 13 May 2026 17:35:24 +0200 Subject: [PATCH 20/24] cmd/devp2p: fix tests --- cmd/devp2p/internal/ethtest/conn.go | 4 ++ cmd/devp2p/internal/ethtest/protocol.go | 2 +- cmd/devp2p/internal/ethtest/suite.go | 84 +++++++++++++------------ 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/cmd/devp2p/internal/ethtest/conn.go b/cmd/devp2p/internal/ethtest/conn.go index 7f5f6a5dd1..41d44b8cdc 100644 --- a/cmd/devp2p/internal/ethtest/conn.go +++ b/cmd/devp2p/internal/ethtest/conn.go @@ -94,6 +94,10 @@ type Conn struct { ourHighestProtoVersion uint ourHighestSnapProtoVersion uint caps []p2p.Cap + + // pending holds messages received by readUntil that did not match the + // caller's expected type. + pending []any } // Read reads a packet from the connection. diff --git a/cmd/devp2p/internal/ethtest/protocol.go b/cmd/devp2p/internal/ethtest/protocol.go index 9da3142f5b..f026c9dd89 100644 --- a/cmd/devp2p/internal/ethtest/protocol.go +++ b/cmd/devp2p/internal/ethtest/protocol.go @@ -32,7 +32,7 @@ const ( // Unexported devp2p protocol lengths from p2p package. const ( baseProtoLen = 16 - ethProtoLen = 20 + ethProtoLen = 22 snapProtoLen = 8 ) diff --git a/cmd/devp2p/internal/ethtest/suite.go b/cmd/devp2p/internal/ethtest/suite.go index db6a10d3d4..4de1f539d7 100644 --- a/cmd/devp2p/internal/ethtest/suite.go +++ b/cmd/devp2p/internal/ethtest/suite.go @@ -914,15 +914,16 @@ func makeSidecar(data ...byte) *types.BlobTxSidecar { return types.NewBlobTxSidecar(types.BlobSidecarVersion1, blobs, commitments, proofs) } -func (s *Suite) makeBlobTxs(count, blobs int, discriminator byte) (txs types.Transactions) { +func (s *Suite) makeBlobTxs(txCount, blobCount int, discriminator byte) (txs types.Transactions, blobs [][]kzg4844.Blob) { from, nonce := s.chain.GetSender(5) - for i := 0; i < count; i++ { + for i := 0; i < txCount; i++ { // Make blob data, max of 2 blobs per tx. - blobdata := make([]byte, min(blobs, 2)) + blobdata := make([]byte, min(blobCount, 2)) for i := range blobdata { blobdata[i] = discriminator - blobs -= 1 + blobCount -= 1 } + sidecar := makeSidecar(blobdata...) inner := &types.BlobTx{ ChainID: uint256.MustFromBig(s.chain.config.ChainID), Nonce: nonce + uint64(i), @@ -930,16 +931,17 @@ func (s *Suite) makeBlobTxs(count, blobs int, discriminator byte) (txs types.Tra GasFeeCap: uint256.MustFromBig(s.chain.Head().BaseFee()), Gas: 100000, BlobFeeCap: uint256.MustFromBig(eip4844.CalcBlobFee(s.chain.config, s.chain.Head().Header())), - BlobHashes: makeSidecar(blobdata...).BlobHashes(), - Sidecar: makeSidecar(blobdata...), + BlobHashes: sidecar.BlobHashes(), + Sidecar: sidecar, } tx, err := s.chain.SignTx(from, types.NewTx(inner)) if err != nil { panic("blob tx signing failed") } - txs = append(txs, tx) + blobs = append(blobs, sidecar.Blobs) + txs = append(txs, tx.WithoutBlob()) } - return txs + return txs, blobs } func (s *Suite) TestBlobViolations(t *utesting.T) { @@ -950,8 +952,8 @@ func (s *Suite) TestBlobViolations(t *utesting.T) { } // Create blob txs for each tests with unique tx hashes. var ( - t1 = s.makeBlobTxs(2, 3, 0x1) - t2 = s.makeBlobTxs(2, 3, 0x2) + t1, _ = s.makeBlobTxs(2, 3, 0x1) + t2, _ = s.makeBlobTxs(2, 3, 0x2) ) for _, test := range []struct { ann eth.NewPooledTransactionHashesPacket72 @@ -1034,22 +1036,29 @@ func mangleSidecar(tx *types.Transaction) *types.Transaction { func (s *Suite) TestBlobTxWithoutSidecar(t *utesting.T) { t.Log(`This test checks that a blob transaction first advertised/transmitted without blobs will result in the sending peer being disconnected, and the full transaction should be successfully retrieved from another peer.`) - tx := s.makeBlobTxs(1, 2, 42)[0].WithoutBlob() - badTx := tx.WithoutBlobTxSidecar() - s.testBadBlobTx(t, tx, badTx) + tx, _ := s.makeBlobTxs(1, 2, 42) + badTx := tx[0].WithoutBlobTxSidecar() + s.testBadBlobTx(t, tx[0], badTx) } func (s *Suite) TestBlobTxWithMismatchedSidecar(t *utesting.T) { t.Log(`This test checks that a blob transaction first advertised/transmitted without blobs, whose commitment don't correspond to the blob_versioned_hashes in the transaction, will result in the sending peer being disconnected, and the full transaction should be successfully retrieved from another peer.`) - tx := s.makeBlobTxs(1, 2, 43)[0].WithoutBlob() - badTx := mangleSidecar(tx) - s.testBadBlobTx(t, tx, badTx) + tx, _ := s.makeBlobTxs(1, 2, 43) + badTx := mangleSidecar(tx[0]) + s.testBadBlobTx(t, tx[0], badTx) } // readUntil reads eth protocol messages until a message of the target type is // received. It returns an error if there is a disconnect, or if the context // is cancelled before a message of the desired type can be read. func readUntil[T any](ctx context.Context, conn *Conn) (*T, error) { + // First check the buffer for a previously-stashed match. + for i, msg := range conn.pending { + if t, ok := msg.(*T); ok { + conn.pending = append(conn.pending[:i], conn.pending[i+1:]...) + return t, nil + } + } for { select { case <-ctx.Done(): @@ -1063,11 +1072,10 @@ func readUntil[T any](ctx context.Context, conn *Conn) (*T, error) { } continue } - - switch res := received.(type) { - case *T: - return res, nil + if t, ok := received.(*T); ok { + return t, nil } + conn.pending = append(conn.pending, received) } } @@ -1225,7 +1233,7 @@ partial fetch GetCells should never arrive. Any GetCells that does arrive must b t.Fatalf("send fcu failed: %v", err) } - txs := s.makeBlobTxs(4, 4, 0x30) + txs, _ := s.makeBlobTxs(4, 4, 0x30) conn, err := s.dial() if err != nil { @@ -1278,15 +1286,7 @@ partial fetch GetCells should never arrive. Any GetCells that does arrive must b t.Fatalf("received partial GetCells request with only %d cells from single peer announcement", req.Mask.OneCount()) } case *eth.GetPooledTransactionsPacket: - var txsWithoutBlob []*types.Transaction - for _, h := range req.GetPooledTransactionsRequest { - for _, tx := range txs { - if tx.Hash() == h { - txsWithoutBlob = append(txsWithoutBlob, tx.WithoutBlob()) - } - } - } - encTxs, _ := rlp.EncodeToRawList(txsWithoutBlob) + encTxs, _ := rlp.EncodeToRawList(txs) conn.Write(ethProto, eth.PooledTransactionsMsg, eth.PooledTransactionsPacket{ RequestId: req.RequestId, List: encTxs, @@ -1296,11 +1296,11 @@ partial fetch GetCells should never arrive. Any GetCells that does arrive must b } // buildCells extracts cells at mask indices from the original tx's blobs -func buildCells(sidecar *types.BlobTxSidecar, mask types.CustodyBitmap) []kzg4844.Cell { - allCells, _ := kzg4844.ComputeCells(sidecar.Blobs) +func buildCells(blobs []kzg4844.Blob, mask types.CustodyBitmap) []kzg4844.Cell { + allCells, _ := kzg4844.ComputeCells(blobs) indices := mask.Indices() - result := make([]kzg4844.Cell, 0, len(sidecar.Blobs)*len(indices)) - for b := 0; b < len(sidecar.Blobs); b++ { + result := make([]kzg4844.Cell, 0, len(blobs)*len(indices)) + for b := 0; b < len(blobs); b++ { for _, idx := range indices { result = append(result, allCells[b*kzg4844.CellsPerBlob+int(idx)]) } @@ -1342,16 +1342,16 @@ func readAnyFrom[T any](conns ...*Conn) (*T, *Conn, error) { } func (s *Suite) TestGetCells(t *utesting.T) { - t.Log(`This test checks that blob tx announcements trigger GetCells requests, + t.Log(`This test checks that blob tx announcements trigger GetCells requests, and that providing valid cells causes the tx to enter the pool.`) if err := s.engine.sendForkchoiceUpdated(); err != nil { t.Fatalf("send fcu failed: %v", err) } - tx := s.makeBlobTxs(1, 1, 0x31)[0] - sidecar := tx.BlobTxSidecar() - tx = tx.WithoutBlob() + txs, blobs := s.makeBlobTxs(1, 1, 0x31) + tx := txs[0] + blob := blobs[0] // Two peers ensure GetCells arrives regardless of full/partial fetch path. conn1, err := s.dial() @@ -1406,7 +1406,7 @@ and that providing valid cells causes the tx to enter the pool.`) } // Respond with valid cells matching the requested mask. - cells := buildCells(sidecar, cellsReq.Mask) + cells := buildCells(blob, cellsReq.Mask) cellsResp := eth.CellsPacket{ RequestId: cellsReq.RequestId, CellsResponse: eth.CellsResponse{ @@ -1433,7 +1433,9 @@ while the other peer is not.`) t.Fatalf("send fcu failed: %v", err) } - tx := s.makeBlobTxs(1, 1, 0x32)[0].WithoutBlob() + txs, blobs := s.makeBlobTxs(1, 1, 0x32) + tx := txs[0] + blob := blobs[0] conn1, err := s.dial() if err != nil { @@ -1482,7 +1484,7 @@ while the other peer is not.`) } // Respond with corrupted cells (all zero bytes). - blobCount := len(tx.BlobTxSidecar().Blobs) + blobCount := len(blob) corrupted := make([]kzg4844.Cell, blobCount*cellsReq.Mask.OneCount()) badResp := eth.CellsPacket{ RequestId: cellsReq.RequestId, From 1a7e0397bbf5faf711cdf32687df34a5bcb9032c Mon Sep 17 00:00:00 2001 From: healthykim Date: Thu, 14 May 2026 19:15:48 +0200 Subject: [PATCH 21/24] core, eth: new engine api spec --- core/types/custody_bitmap.go | 32 ++++++++------------------------ eth/catalyst/api.go | 9 ++++----- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/core/types/custody_bitmap.go b/core/types/custody_bitmap.go index d3624bb914..74585cdc42 100644 --- a/core/types/custody_bitmap.go +++ b/core/types/custody_bitmap.go @@ -18,47 +18,31 @@ package types import ( "fmt" - "math/big" "math/bits" "math/rand" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto/kzg4844" ) // CustodyBitmap is a bitmap to represent which custody index to store (little endian). -// It is serialized as a hex-encoded uint128 quantity (e.g. "0x89") for JSON-RPC. type CustodyBitmap [16]byte // MarshalText implements encoding.TextMarshaler. -// Encodes the bitmap as a hex-encoded uint128 quantity. func (b CustodyBitmap) MarshalText() ([]byte, error) { - var be [16]byte - for i := range b { - be[15-i] = b[i] - } - v := new(big.Int).SetBytes(be[:]) - return []byte("0x" + v.Text(16)), nil + return []byte(hexutil.Encode(b[:])), nil } // UnmarshalText implements encoding.TextUnmarshaler. -// Parses a hex-encoded uint128 quantity into the bitmap. func (b *CustodyBitmap) UnmarshalText(input []byte) error { - s := string(input) - if len(s) < 2 || (s[:2] != "0x" && s[:2] != "0X") { - return fmt.Errorf("custody bitmap: missing 0x prefix") + decoded, err := hexutil.Decode(string(input)) + if err != nil { + return fmt.Errorf("custody bitmap: %v", err) } - v, ok := new(big.Int).SetString(s[2:], 16) - if !ok { - return fmt.Errorf("custody bitmap: invalid hex %q", s) - } - if v.BitLen() > 128 { - return fmt.Errorf("custody bitmap: value exceeds 128 bits") - } - *b = CustodyBitmap{} - beBytes := v.Bytes() // big-endian - for i, byt := range beBytes { - b[len(beBytes)-1-i] = byt + if len(decoded) != len(b) { + return fmt.Errorf("custody bitmap: invalid length %d, want %d", len(decoded), len(b)) } + copy(b[:], decoded) return nil } diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index b980920493..ebe0b880aa 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -214,7 +214,7 @@ func (api *ConsensusAPI) ForkchoiceUpdatedV3(ctx context.Context, update engine. // ForkchoiceUpdatedV4 is equivalent to V3 with the addition of slot number // in the payload attributes. It supports only PayloadAttributesV4. -func (api *ConsensusAPI) ForkchoiceUpdatedV4(ctx context.Context, update engine.ForkchoiceStateV1, params *engine.PayloadAttributes) (engine.ForkChoiceResponse, error) { +func (api *ConsensusAPI) ForkchoiceUpdatedV4(ctx context.Context, update engine.ForkchoiceStateV1, params *engine.PayloadAttributes, custodyColumns *types.CustodyBitmap) (engine.ForkChoiceResponse, error) { if params != nil { switch { case params.Withdrawals == nil: @@ -227,6 +227,9 @@ func (api *ConsensusAPI) ForkchoiceUpdatedV4(ctx context.Context, update engine. return engine.STATUS_INVALID, unsupportedForkErr("fcuV4 must only be called for amsterdam payloads") } } + if custodyColumns != nil { + api.eth.BlobFetcher().UpdateCustody(*custodyColumns) + } // TODO(matt): the spec requires that fcu is applied when called on a valid // hash, even if params are wrong. To do this we need to split up // forkchoiceUpdate into a function that only updates the head and then a @@ -1218,10 +1221,6 @@ func (api *ConsensusAPI) getBodiesByRange(start, count hexutil.Uint64) ([]*engin return bodies, nil } -func (api *ConsensusAPI) BlobCustodyUpdatedV1(indicesBitarray types.CustodyBitmap) { - api.eth.BlobFetcher().UpdateCustody(indicesBitarray) -} - func getBody(block *types.Block) *engine.ExecutionPayloadBody { if block == nil { return nil From 7aa045c60c17d52a928cb78989df630dcc3b18d2 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Thu, 14 May 2026 19:51:41 +0200 Subject: [PATCH 22/24] core/txpool/blobpool: add metrics for blob buffer --- core/txpool/blobpool/buffer.go | 35 +++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/core/txpool/blobpool/buffer.go b/core/txpool/blobpool/buffer.go index e9695849ea..35ffcf6018 100644 --- a/core/txpool/blobpool/buffer.go +++ b/core/txpool/blobpool/buffer.go @@ -26,6 +26,14 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/metrics" +) + +var ( + blobBufferTxFirstCounter = metrics.NewRegisteredCounter("blobpool/buffer/txfirst", nil) + blobBufferCellsFirstCounter = metrics.NewRegisteredCounter("blobpool/buffer/cellsfirst", nil) + blobBufferTotalTx = metrics.NewRegisteredGauge("blobpool/buffer/txcount", nil) + blobBufferTotalCells = metrics.NewRegisteredGauge("blobpool/buffer/cellcount", nil) ) const ( @@ -70,6 +78,9 @@ func NewBlobBuffer(addToPool func(*PooledBlobTx) error, dropPeer func(string)) * // AddTx buffers a blob transaction (without blobs) from an ETH/72 peer. // If cells are already buffered, verification and pool insertion are attempted. func (b *BlobBuffer) AddTx(tx *types.Transaction, peer string) error { + defer b.updateMetrics()() + + // First remove any timed-out entries. b.evict() hash := tx.Hash() @@ -98,6 +109,7 @@ func (b *BlobBuffer) AddTx(tx *types.Transaction, peer string) error { if entry, ok := b.cells[hash]; ok { return b.add(hash, tx, entry) } + blobBufferTxFirstCounter.Inc(1) b.txs[hash] = &txEntry{tx: tx, peer: peer, added: time.Now()} return nil } @@ -105,16 +117,20 @@ func (b *BlobBuffer) AddTx(tx *types.Transaction, peer string) error { // AddCells buffers per-peer cell deliveries from the blob fetcher. // If the transaction is already buffered, verification and pool insertion are attempted. func (b *BlobBuffer) AddCells(hash common.Hash, deliveries map[string]*PeerDelivery, custody *types.CustodyBitmap) error { + defer b.updateMetrics()() + + // First remove any timed-out entries. b.evict() + b.cells[hash] = &cellEntry{ deliveries: deliveries, custody: custody, added: time.Now(), } - if txe, ok := b.txs[hash]; ok { return b.add(hash, txe.tx, b.cells[hash]) } + blobBufferCellsFirstCounter.Inc(1) return nil } @@ -184,6 +200,23 @@ func (b *BlobBuffer) evict() { } } +// updateMetrics updates the metrics gauges. +// This should be called at the start of any operation that changes the buffer +// content. The returned function is to be called at the end of the operation, +// usually with defer. +func (b *BlobBuffer) updateMetrics() func() { + preTxCount := len(b.txs) + preCellsCount := len(b.cells) + return func() { + if len(b.txs) != preTxCount { + blobBufferTotalTx.Update(int64(len(b.txs))) + } + if len(b.cells) != preCellsCount { + blobBufferTotalCells.Update(int64(len(b.cells))) + } + } +} + // verifyCells verifies each peer's cells against the sidecar. // Returns the list of peers whose cells failed verification. func (b *BlobBuffer) verifyCells(entry *cellEntry, sidecar *types.BlobTxSidecar) []string { From 513a834d0af7be206b9798b981e8065f975a6ce3 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Thu, 14 May 2026 20:16:18 +0200 Subject: [PATCH 23/24] eth/fetcher: pass BlobFetcher callbacks in struct --- eth/fetcher/blob_fetcher.go | 64 +++++++-------- eth/fetcher/blob_fetcher_test.go | 134 +++++++++++++++++++------------ eth/handler.go | 40 ++++----- 3 files changed, 135 insertions(+), 103 deletions(-) diff --git a/eth/fetcher/blob_fetcher.go b/eth/fetcher/blob_fetcher.go index 7ceb019dcd..089a946b1a 100644 --- a/eth/fetcher/blob_fetcher.go +++ b/eth/fetcher/blob_fetcher.go @@ -89,6 +89,13 @@ type fetchStatus struct { blobCount int // Number of blobs in this tx (set on first delivery) } +type BlobFetcherFunctions struct { + HasPayload func(common.Hash) bool + AddCells func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error + FetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error + DropPeer func(string) +} + // BlobFetcher is responsible for managing type 3 transactions based on peer announcements. // // BlobFetcher manages three buffers: @@ -128,11 +135,7 @@ type BlobFetcher struct { // todo simplify alternates map[common.Hash]map[string]*types.CustodyBitmap // In-flight transaction alternate origins (in case the peer is dropped) - // Callbacks - hasPayload func(common.Hash) bool - addCells func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error - fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error - dropPeer func(string) + fn BlobFetcherFunctions // callbacks step chan struct{} // Notification channel when the fetcher loop iterates clock mclock.Clock // Monotonic clock or simulated clock for tests @@ -140,33 +143,26 @@ type BlobFetcher struct { rand random // Randomizer } -func NewBlobFetcher( - hasPayload func(common.Hash) bool, - addCells func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error, - fetchPayloads func(string, []common.Hash, *types.CustodyBitmap) error, dropPeer func(string), - custody *types.CustodyBitmap, rand random) *BlobFetcher { +func NewBlobFetcher(fn BlobFetcherFunctions, custody *types.CustodyBitmap, rand random) *BlobFetcher { return &BlobFetcher{ - notify: make(chan *blobTxAnnounce), - cleanup: make(chan *payloadDelivery), - drop: make(chan *txDrop), - quit: make(chan struct{}), - full: make(map[common.Hash]struct{}), - partial: make(map[common.Hash]struct{}), - waitlist: make(map[common.Hash]map[string]struct{}), - waittime: make(map[common.Hash]mclock.AbsTime), - waitslots: make(map[string]map[common.Hash]struct{}), - announces: make(map[string]map[common.Hash]*cellWithSeq), - fetches: make(map[common.Hash]*fetchStatus), - requests: make(map[string][]*cellRequest), - alternates: make(map[common.Hash]map[string]*types.CustodyBitmap), - hasPayload: hasPayload, - addCells: addCells, - fetchPayloads: fetchPayloads, - dropPeer: dropPeer, - custody: custody, - clock: mclock.System{}, - realTime: time.Now, - rand: rand, + notify: make(chan *blobTxAnnounce), + cleanup: make(chan *payloadDelivery), + drop: make(chan *txDrop), + quit: make(chan struct{}), + full: make(map[common.Hash]struct{}), + partial: make(map[common.Hash]struct{}), + waitlist: make(map[common.Hash]map[string]struct{}), + waittime: make(map[common.Hash]mclock.AbsTime), + waitslots: make(map[string]map[common.Hash]struct{}), + announces: make(map[string]map[common.Hash]*cellWithSeq), + fetches: make(map[common.Hash]*fetchStatus), + requests: make(map[string][]*cellRequest), + alternates: make(map[common.Hash]map[string]*types.CustodyBitmap), + fn: fn, + custody: custody, + clock: mclock.System{}, + realTime: time.Now, + rand: rand, } } @@ -175,7 +171,7 @@ func (f *BlobFetcher) Notify(peer string, txs []common.Hash, cells types.Custody blobAnnounceInMeter.Mark(int64(len(txs))) anns := make([]common.Hash, 0) for _, tx := range txs { - if f.hasPayload(tx) { + if f.fn.HasPayload(tx) { continue } anns = append(anns, tx) @@ -521,7 +517,7 @@ func (f *BlobFetcher) loop() { blobFetcherFetchTime.Update(int64(time.Duration(f.clock.Now() - request.time))) status := f.fetches[hash] collectedCustody := types.NewCustodyBitmap(status.fetched) - f.addCells(hash, status.deliveries, &collectedCustody) + f.fn.AddCells(hash, status.deliveries, &collectedCustody) for peer, txset := range f.announces { delete(txset, hash) @@ -770,7 +766,7 @@ func (f *BlobFetcher) scheduleFetches(timer *mclock.Timer, timeout chan struct{} go func(peer string, request []*cellRequest) { for _, req := range request { blobRequestOutMeter.Mark(int64(len(req.txs))) - if err := f.fetchPayloads(peer, req.txs, req.cells); err != nil { + if err := f.fn.FetchPayloads(peer, req.txs, req.cells); err != nil { blobRequestFailMeter.Mark(int64(len(req.txs))) f.Drop(peer) break diff --git a/eth/fetcher/blob_fetcher_test.go b/eth/fetcher/blob_fetcher_test.go index 589957bf23..e89f7602ed 100644 --- a/eth/fetcher/blob_fetcher_test.go +++ b/eth/fetcher/blob_fetcher_test.go @@ -147,12 +147,16 @@ func TestBlobFetcherFullFetch(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { - return nil + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 5}, // Force full requests (5 < fetchProbability) ) @@ -236,12 +240,16 @@ func TestBlobFetcherPartialFetch(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { - return nil + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 60}, // Force partial requests (20 >= 15) ) @@ -329,12 +337,16 @@ func TestBlobFetcherFullDelivery(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { - return nil + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 5}, // Force full requests for simplicity ) @@ -375,12 +387,16 @@ func TestBlobFetcherPartialDelivery(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { - return nil + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 60}, ) @@ -509,12 +525,16 @@ func TestBlobFetcherAvailabilityTimeout(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { - return nil + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 60}, ) @@ -549,12 +569,16 @@ func TestBlobFetcherPeerDrop(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { - return nil + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 5}, ) @@ -624,12 +648,16 @@ func TestBlobFetcherFetchTimeout(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { - return nil + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(common.Hash, map[string]*PeerCellDelivery, *types.CustodyBitmap) error { + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 5}, ) @@ -1010,25 +1038,29 @@ func TestMultiBlobDeliveryVerification(t *testing.T) { testBlobFetcher(t, blobFetcherTest{ init: func() *BlobFetcher { return NewBlobFetcher( - func(common.Hash) bool { return false }, - func(hash common.Hash, deliveries map[string]*PeerCellDelivery, cst *types.CustodyBitmap) error { - // Verify each peer's delivered cells pass KZG cell proof verification - for _, d := range deliveries { - var cellProofs []kzg4844.Proof - for blobIdx := 0; blobIdx < len(sidecar.Commitments); blobIdx++ { - for _, idx := range d.Indices { - cellProofs = append(cellProofs, sidecar.Proofs[blobIdx*kzg4844.CellProofsPerBlob+int(idx)]) + BlobFetcherFunctions{ + HasPayload: func(common.Hash) bool { return false }, + AddCells: func(h common.Hash, deliveries map[string]*PeerCellDelivery, custody *types.CustodyBitmap) error { + // Verify each peer's delivered cells pass KZG cell proof verification + for _, d := range deliveries { + var cellProofs []kzg4844.Proof + for blobIdx := 0; blobIdx < len(sidecar.Commitments); blobIdx++ { + for _, idx := range d.Indices { + cellProofs = append(cellProofs, sidecar.Proofs[blobIdx*kzg4844.CellProofsPerBlob+int(idx)]) + } + } + verifyErr = kzg4844.VerifyCells(d.Cells, sidecar.Commitments, cellProofs, d.Indices) + if verifyErr != nil { + return verifyErr } } - verifyErr = kzg4844.VerifyCells(d.Cells, sidecar.Commitments, cellProofs, d.Indices) - if verifyErr != nil { - return verifyErr - } - } - return nil + return nil + }, + FetchPayloads: func(string, []common.Hash, *types.CustodyBitmap) error { + return nil + }, + DropPeer: func(string) {}, }, - func(string, []common.Hash, *types.CustodyBitmap) error { return nil }, - func(string) {}, &custody, &mockRand{value: 60}, // Force partial requests (60 >= fetchProbability) ) diff --git a/eth/handler.go b/eth/handler.go index 539c8a7c6d..d22c0af572 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -195,7 +195,8 @@ func newHandler(config *handlerConfig) (*handler, error) { } return p.RequestTxs(hashes) } - // Construct the blob buffer for assembling blob txs from separate tx and cell deliveries + + // Construct the blob buffer for assembling blob txs from separate tx and cell deliveries. h.blobBuffer = blobpool.NewBlobBuffer(h.blobpool.AddPooledTx, h.removePeer) addTxs := func(peer string, txs []*types.Transaction) []error { @@ -233,24 +234,27 @@ func newHandler(config *handlerConfig) (*handler, error) { h.txFetcher = fetcher.NewTxFetcher(h.chain, validateMeta, addTxs, fetchTx, h.removePeer) // Construct the blob fetcher for cell-based blob data availability - fetchPayloads := func(peer string, hashes []common.Hash, cells *types.CustodyBitmap) error { - p := h.peers.peer(peer) - if p == nil { - return errors.New("unknown peer") - } - return p.RequestPayload(hashes, cells) + blobCallbacks := fetcher.BlobFetcherFunctions{ + FetchPayloads: func(peer string, hashes []common.Hash, cells *types.CustodyBitmap) error { + p := h.peers.peer(peer) + if p == nil { + return errors.New("unknown peer") + } + return p.RequestPayload(hashes, cells) + }, + HasPayload: func(hash common.Hash) bool { + return h.blobpool.HasPayload(hash) || h.blobBuffer.HasCells(hash) + }, + AddCells: func(hash common.Hash, deliveries map[string]*fetcher.PeerCellDelivery, custody *types.CustodyBitmap) error { + converted := make(map[string]*blobpool.PeerDelivery, len(deliveries)) + for peer, d := range deliveries { + converted[peer] = &blobpool.PeerDelivery{Cells: d.Cells, Indices: d.Indices} + } + return h.blobBuffer.AddCells(hash, converted, custody) + }, + DropPeer: h.removePeer, } - hasPayload := func(hash common.Hash) bool { - return h.blobpool.HasPayload(hash) || h.blobBuffer.HasCells(hash) - } - addCells := func(hash common.Hash, deliveries map[string]*fetcher.PeerCellDelivery, custody *types.CustodyBitmap) error { - converted := make(map[string]*blobpool.PeerDelivery, len(deliveries)) - for peer, d := range deliveries { - converted[peer] = &blobpool.PeerDelivery{Cells: d.Cells, Indices: d.Indices} - } - return h.blobBuffer.AddCells(hash, converted, custody) - } - h.blobFetcher = fetcher.NewBlobFetcher(hasPayload, addCells, fetchPayloads, h.removePeer, &config.Custody, nil) + h.blobFetcher = fetcher.NewBlobFetcher(blobCallbacks, &config.Custody, nil) return h, nil } From 923bf0192ce305909b2a148e84f861a912d7881f Mon Sep 17 00:00:00 2001 From: healthykim Date: Mon, 18 May 2026 18:04:29 +0200 Subject: [PATCH 24/24] add tx validation in buffer --- core/txpool/blobpool/blobpool.go | 2 +- core/txpool/blobpool/buffer.go | 34 ++++++++++++++++------------- core/txpool/blobpool/buffer_test.go | 3 +++ eth/handler.go | 3 ++- eth/handler_test.go | 4 ++++ 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index 0fbb5cedbf..2c698ac7a6 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1485,7 +1485,7 @@ func (p *BlobPool) ValidateTxBasics(tx *types.Transaction) error { Accept: 1 << types.BlobTxType, MaxSize: txMaxSize, MinTip: p.gasTip.Load().ToBig(), - MaxBlobCount: maxBlobsPerTx, + MaxBlobCount: maxBlobsPerTx, //todo this field is currently not being used } return txpool.ValidateTransaction(tx, p.head.Load(), p.signer, opts) } diff --git a/core/txpool/blobpool/buffer.go b/core/txpool/blobpool/buffer.go index e516cf1a41..86301dcbf5 100644 --- a/core/txpool/blobpool/buffer.go +++ b/core/txpool/blobpool/buffer.go @@ -29,6 +29,7 @@ import ( "github.com/ethereum/go-ethereum/metrics" ) +// todo: per-peer size limit var ( blobBufferTxFirstCounter = metrics.NewRegisteredCounter("blobpool/buffer/txfirst", nil) blobBufferCellsFirstCounter = metrics.NewRegisteredCounter("blobpool/buffer/cellsfirst", nil) @@ -47,7 +48,9 @@ type PeerDelivery struct { } type txEntry struct { - tx *types.Transaction + tx *types.Transaction + // Technically it is not required to store peer information to drop properly. + // This is mainly for per peer size limit check. peer string added time.Time } @@ -62,16 +65,18 @@ type BlobBuffer struct { txs map[common.Hash]*txEntry cells map[common.Hash]*cellEntry - addToPool func(*BlobTxForPool) error - dropPeer func(string) + addToPool func(*BlobTxForPool) error + validateTx func(*types.Transaction) error + dropPeer func(string) } -func NewBlobBuffer(addToPool func(*BlobTxForPool) error, dropPeer func(string)) *BlobBuffer { +func NewBlobBuffer(validateTx func(*types.Transaction) error, addToPool func(*BlobTxForPool) error, dropPeer func(string)) *BlobBuffer { return &BlobBuffer{ - txs: make(map[common.Hash]*txEntry), - cells: make(map[common.Hash]*cellEntry), - addToPool: addToPool, - dropPeer: dropPeer, + txs: make(map[common.Hash]*txEntry), + cells: make(map[common.Hash]*cellEntry), + validateTx: validateTx, + addToPool: addToPool, + dropPeer: dropPeer, } } @@ -88,6 +93,12 @@ func (b *BlobBuffer) AddTx(tx *types.Transaction, peer string) error { if sidecar == nil { return fmt.Errorf("blob transaction without sidecar") } + // tx validation + if err := b.validateTx(tx); err != nil { + log.Warn("Transaction validation failed, dropping peer", "peer", peer, "err", err) + b.dropPeer(peer) + return err + } // vhash check if err := sidecar.ValidateBlobCommitmentHashes(tx.BlobHashes()); err != nil { log.Warn("Commitment hash mismatch, dropping peer", "peer", peer, "err", err) @@ -99,13 +110,6 @@ func (b *BlobBuffer) AddTx(tx *types.Transaction, peer string) error { b.dropPeer(peer) return fmt.Errorf("insufficient proofs in sidecar") } - // todo: I also considered performing additional validation for the metrics of the - // tx_fetcher. This could be used to avoid sending GetCells requests when the - // nonce is too low or the transaction is underpriced. However, doing so would - // require taking buffered transactions into account as well, and would require - // allowing the buffer to be part of the fetcher’s scheduling logic. - // Therefore, I will leave this as a TODO for now. - if entry, ok := b.cells[hash]; ok { return b.add(hash, tx, entry) } diff --git a/core/txpool/blobpool/buffer_test.go b/core/txpool/blobpool/buffer_test.go index 9aee4df57b..67abde7ad4 100644 --- a/core/txpool/blobpool/buffer_test.go +++ b/core/txpool/blobpool/buffer_test.go @@ -43,6 +43,7 @@ func makePeerDelivery(t *testing.T, blobOffset, blobCount int, indices []uint64) func newTestBuffer(t *testing.T) *BlobBuffer { t.Helper() return NewBlobBuffer( + func(tx *types.Transaction) error { return nil }, func(ptx *BlobTxForPool) error { return nil }, func(peer string) {}, ) @@ -180,6 +181,7 @@ func TestBadCell(t *testing.T) { var dropped []string buf := NewBlobBuffer( + func(tx *types.Transaction) error { return nil }, func(ptx *BlobTxForPool) error { return nil }, func(peer string) { dropped = append(dropped, peer) }, ) @@ -220,6 +222,7 @@ func TestBadTx(t *testing.T) { var dropped []string buf := NewBlobBuffer( + func(tx *types.Transaction) error { return nil }, func(ptx *BlobTxForPool) error { return nil }, func(peer string) { dropped = append(dropped, peer) }, ) diff --git a/eth/handler.go b/eth/handler.go index 258876ed07..dc6f0ed1b4 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -107,6 +107,7 @@ type blobPool interface { GetBlobCells(vhashes []common.Hash, mask types.CustodyBitmap) ([][]*kzg4844.Cell, [][]*kzg4844.Proof, error) GetCustody(hash common.Hash) *types.CustodyBitmap AddPooledTx(pooledTx *blobpool.BlobTxForPool) error + ValidateTxBasics(pooledTx *types.Transaction) error } // handlerConfig is the collection of initialization parameters to create a full @@ -189,7 +190,7 @@ func newHandler(config *handlerConfig) (*handler, error) { } // Construct the blob buffer for assembling blob txs from separate tx and cell deliveries. - h.blobBuffer = blobpool.NewBlobBuffer(h.blobpool.AddPooledTx, h.removePeer) + h.blobBuffer = blobpool.NewBlobBuffer(h.blobpool.ValidateTxBasics, h.blobpool.AddPooledTx, h.removePeer) addTxs := func(peer string, txs []*types.Transaction) []error { errs := make([]error, len(txs)) diff --git a/eth/handler_test.go b/eth/handler_test.go index f1456f8e6f..0f3aa074ad 100644 --- a/eth/handler_test.go +++ b/eth/handler_test.go @@ -271,6 +271,10 @@ func (p *testTxPool) FilterType(kind byte) bool { return false } +func (p *testTxPool) ValidateTxBasics(_ *types.Transaction) error { + return nil +} + // testHandler is a live implementation of the Ethereum protocol handler, just // preinitialized with some sane testing defaults and the transaction pool mocked // out.