go-ethereum/eth/fetcher/blob_fetcher_test.go
Bosul Mun d91b71fb36
core/txpool/blobpool, eth: implement sparse blobpool (#34047)
This is the implementation of EIP-8070 Sparse Blobpool. It introduces
protocol version eth/72 which relays blob transaction cells instead of
full blobs.

The blobpool now store 'incomplete' transactions, where only some of
the cells are provided. The stored cell indexes are taken from the
custody bitmap, which is provided by the consensus layer in
forkchoiceUpdatedV4. This method will be called once Glamsterdam
activates, and the default custody is full custody, so for now there
is no change in the amount of stored cells for now.

The main entities added are the BlobBuffer and BlobFetcher, which work
together to track and fetch missing cells from connected peers. The
partial transactions become available for inclusion in blocks when
they are covered by enough peers that hold all cells.

This change also introduces engine_getBlobsV4, which allows for
cell-based responses (and partial blobs with only some of the cells).
We maintain backward compatibility with getBlobsV3 which expects full
blob responses by recovering the blob from available cells. Since this
process is resource-intensive, we proactively cache the conversion so
it is ready in time for getBlobsV3 calls. This mechanism will be
removed once support for getBlobsV4 is universal across all consensus
layer implementations.

devp2p tests for eth/72 are not part of this initial change. This is
to avoid breaking test success status for execution clients that do
not have eth/72 implemented yet. The tests will be added in a
subsequent change.

---------

Co-authored-by: Felix Lange <fjl@twurst.com>
2026-07-15 14:41:04 +02:00

1152 lines
38 KiB
Go

// 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 <http://www.gnu.org/licenses/>.
package fetcher
import (
"slices"
"testing"
"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...)
}
cells, _ := kzg4844.ComputeCells(blobs)
return &types.BlobTxCellSidecar{
Version: types.BlobSidecarVersion1,
Cells: cells,
Commitments: commitments,
Proofs: proofs,
Custody: types.CustodyBitmapAll,
}
}
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
}
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(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false },
AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 5}, // Force full requests (5 < fetchProbability)
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(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false }, AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 60}, // Force partial requests (20 >= 15)
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}},
},
},
},
})
}
// TestBlobFetcherFullDelivery tests cell delivery and fetch completion logic (full fetch)
func TestBlobFetcherFullDelivery(t *testing.T) {
testBlobFetcher(t, blobFetcherTest{
init: func() *BlobFetcher {
return NewBlobFetcher(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false }, AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 5}, // Force full requests for simplicity
15,
)
},
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(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false }, AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 60},
15,
)
},
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(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false }, AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 60},
15,
)
},
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 → partial converts to full, peer A moves to announces
doWait{time: blobAvailabilityTimeout, step: true},
// After timeout, waitlist should be empty but tx promoted to full fetch
isWaitingAvailability{},
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}},
},
},
},
})
}
// TestBlobFetcherPeerDrop tests peer drop scenarios
func TestBlobFetcherPeerDrop(t *testing.T) {
testBlobFetcher(t, blobFetcherTest{
init: func() *BlobFetcher {
return NewBlobFetcher(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false }, AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 5},
15,
)
},
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},
},
})
}
// TestBlobFetcherPartialUnrecoverable checks that a partial fetch is discarded
// once the remaining announcing peers can no longer cover the required custody
func TestBlobFetcherPartialUnrecoverable(t *testing.T) {
testBlobFetcher(t, blobFetcherTest{
init: func() *BlobFetcher {
return NewBlobFetcher(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false },
AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error { return nil },
DropPeer: func(string) {},
},
custody,
&mockRand{value: 60}, // Force partial requests
15,
)
},
steps: []interface{}{
// Two full announces pass the availability check and start a partial fetch (A requested).
doBlobNotify{peer: "A", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody},
doBlobNotify{peer: "C", hashes: []common.Hash{testBlobTxHashes[0]}, custody: fullCustody},
isFetching{hashes: map[common.Hash]fetchInfo{
testBlobTxHashes[0]: {fetching: &custody, fetched: []uint64{}},
}},
// Drop A: C (full) takes over the whole in-flight custody.
doDrop("A"),
isBlobScheduled{
announces: map[string][]blobAnnounce{
"C": {{hash: testBlobTxHashes[0], custody: custody}},
},
fetching: map[string][]blobAnnounce{
"C": {{hash: testBlobTxHashes[0], custody: custody}},
},
},
// A partial peer announces part of the custody. C already has all of it in
// flight, so B is only recorded as an alternate (no request of its own).
doBlobNotify{peer: "B", hashes: []common.Hash{testBlobTxHashes[0]}, custody: frontCustody},
isBlobScheduled{
announces: map[string][]blobAnnounce{
"C": {{hash: testBlobTxHashes[0], custody: custody}},
"B": {{hash: testBlobTxHashes[0], custody: frontCustody.Intersection(custody)}},
},
fetching: map[string][]blobAnnounce{
"C": {{hash: testBlobTxHashes[0], custody: custody}},
},
},
// Drop C: only B's partial custody remains, which cannot cover the
// required custody. The tx is unrecoverable, so every trace of it is
// discarded, including B's lingering announce.
doDrop("C"),
isFetching{hashes: nil},
isBlobScheduled{
announces: nil,
fetching: nil,
},
},
})
}
// TestBlobFetcherFetchTimeout tests fetch timeout and rescheduling, full request case
func TestBlobFetcherFetchTimeout(t *testing.T) {
testBlobFetcher(t, blobFetcherTest{
init: func() *BlobFetcher {
return NewBlobFetcher(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false }, AddCells: func(common.Hash, map[string]*PeerCellDelivery, types.CustodyBitmap) {},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 5},
15,
)
},
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: blobFetchTimeout, 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: blobFetchTimeout, 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:
// 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 != *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
}
}
}
// 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(
BlobFetcherFunctions{
HasPayload: func(common.Hash) bool { return false },
AddCells: func(h common.Hash, deliveries map[string]*PeerCellDelivery, custody types.CustodyBitmap) {
// 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)
}
},
FetchPayloads: func(string, []common.Hash, types.CustodyBitmap) error {
return nil
},
DropPeer: func(string) {},
},
custody,
&mockRand{value: 60}, // Force partial requests (60 >= fetchProbability)
15,
)
},
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)
}
}