crypto/kzg4844: add BlobsFromDataCells for zero-KZG blob reconstruction (#35528)
Some checks are pending
/ Linux Build (push) Waiting to run
/ Linux Build (arm) (push) Waiting to run
/ Keeper Build (push) Waiting to run
/ Windows Build (push) Waiting to run
/ Docker Image (push) Waiting to run

When the full data domain (cell indices 0..DataPerBlob-1) is present, a
blob is the concatenation of its data cells, so it can be reconstructed
without any KZG work. Add BlobsFromDataCells, which returns the blobs by
concatenation when the data cells are present in canonical order and
declines otherwise (so callers fall back to RecoverBlobs). Its accepted
inputs are a strict subset of those accepted by RecoverBlobs, so a
successful result is byte-identical; being pure byte copying it is
independent of the selected KZG backend.

Shared primitive: used both to skip KZG recovery when serving blobs and
by the cell-recovery path (RecoverCells).
This commit is contained in:
Csaba Kiraly 2026-08-17 15:08:13 +02:00 committed by GitHub
parent f1df7a0e26
commit 6017c756e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 254 additions and 0 deletions

View file

@ -59,6 +59,11 @@ func (c *Cell) MarshalText() ([]byte, error) {
// Blob represents a 4844 data blob.
type Blob [131072]byte
// A blob is exactly its DataPerBlob data cells, concatenated; the remaining
// cells (indices DataPerBlob..CellsPerBlob-1) carry redundancy only. Code
// reassembling blobs from cells relies on this, so assert it at compile time.
var _ [len(Blob{})]byte = [DataPerBlob * len(Cell{})]byte{}
// UnmarshalJSON parses a blob in hex syntax.
func (b *Blob) UnmarshalJSON(input []byte) error {
return hexutil.UnmarshalFixedJSON(blobT, input, b[:])
@ -265,6 +270,64 @@ func RecoverBlobs(cells []Cell, cellIndices []uint64) ([]Blob, error) {
return gokzgRecoverBlobs(cells, cellIndices)
}
// blobsFromDataCells reconstructs blobs by concatenating their data cells (cell
// indices 0..DataPerBlob-1, by definition the blob contents), with no KZG
// involvement and no validation of the cell contents whatsoever (see
// RecoverBlobsUnchecked for the contract). It accepts a strict subset of the
// inputs RecoverBlobs accepts and returns identical bytes; on ok=false the
// caller should fall back to RecoverBlobs.
//
// For the layout of cells and cellIndices, see RecoverBlobs.
func blobsFromDataCells(cells []Cell, cellIndices []uint64) ([]Blob, bool) {
if validateCellIndices(cells, cellIndices) != nil {
return nil, false
}
// The head must be exactly the data cells in canonical order:
// cellIndices[i] == i for i < DataPerBlob.
if len(cellIndices) < DataPerBlob {
return nil, false
}
for i := range DataPerBlob {
if cellIndices[i] != uint64(i) {
return nil, false
}
}
// The tail is ignored by the concatenation but must still be well-formed:
// the KZG library that would reject it is never reached on this path.
for i := DataPerBlob; i < len(cellIndices); i++ {
if cellIndices[i] <= cellIndices[i-1] || cellIndices[i] >= CellsPerBlob {
return nil, false
}
}
blobCount := len(cells) / len(cellIndices)
blobs := make([]Blob, blobCount)
for b := range blobCount {
data := cells[b*len(cellIndices):][:DataPerBlob]
for i := range data {
copy(blobs[b][i*len(data[i]):], data[i][:])
}
}
return blobs, true
}
// RecoverBlobsUnchecked is RecoverBlobs for callers that have already
// established the cells' authenticity (e.g. via VerifyCells at ingest): when the
// data cells are all present, the blobs are returned as their concatenation,
// skipping the KZG erasure decode. The result is byte-identical to RecoverBlobs.
//
// "Unchecked" refers to the cell contents: the concatenation validates nothing,
// not even field-element canonicalness -- the one check RecoverBlobs performs
// (neither verifies cell proofs or the commitment). Pass only cells whose
// authenticity is already assured.
//
// For the layout of cells and cellIndices, see RecoverBlobs.
func RecoverBlobsUnchecked(cells []Cell, cellIndices []uint64) ([]Blob, error) {
if blobs, ok := blobsFromDataCells(cells, cellIndices); ok {
return blobs, nil
}
return RecoverBlobs(cells, cellIndices)
}
func validateCellIndices(cells []Cell, cellIndices []uint64) error {
switch {
case len(cellIndices) == 0:

View file

@ -434,3 +434,194 @@ func testRecoverBlobWithInsufficientCells(t *testing.T, ckzg bool) {
t.Fatalf("expected error with only %d cells, got none", len(indices))
}
}
func TestCKZGBlobsFromDataCells(t *testing.T) { testBlobsFromDataCells(t, true) }
func TestGoKZGBlobsFromDataCells(t *testing.T) { testBlobsFromDataCells(t, false) }
// testBlobsFromDataCells checks that the KZG-free fast path reconstructs the
// original blobs whenever the data cells are present, agrees byte-for-byte with
// RecoverBlobs, and declines (ok=false) when a data cell is missing.
func testBlobsFromDataCells(t *testing.T, ckzg bool) {
defer switchBackend(t, ckzg)()
const blobCount = 3
d := newBlobs(t, blobCount)
// collect gathers the cells for the given per-blob indices across all blobs.
collect := func(indices []uint64) []Cell {
var cells []Cell
for bi := range blobCount {
for _, idx := range indices {
cells = append(cells, d.cells[bi*CellsPerBlob+int(idx)])
}
}
return cells
}
// assertRecovers checks the fast path succeeds and matches both the original
// blobs and RecoverBlobs.
assertRecovers := func(name string, indices []uint64) {
t.Helper()
cells := collect(indices)
fast, ok := blobsFromDataCells(cells, indices)
if !ok {
t.Fatalf("%s: fast path declined, expected success", name)
}
slow, err := RecoverBlobs(cells, indices)
if err != nil {
t.Fatalf("%s: RecoverBlobs failed: %v", name, err)
}
for i := range d.blobs {
if fast[i] != d.blobs[i] {
t.Fatalf("%s: fast blob %d does not match original", name, i)
}
if fast[i] != slow[i] {
t.Fatalf("%s: fast blob %d does not match RecoverBlobs", name, i)
}
}
}
// Exactly the data cells, in canonical order.
dataIndices := make([]uint64, DataPerBlob)
for i := range dataIndices {
dataIndices[i] = uint64(i)
}
assertRecovers("data-only", dataIndices)
// Full custody: all cells present, data cells plus extension cells.
allIndices := make([]uint64, CellsPerBlob)
for i := range allIndices {
allIndices[i] = uint64(i)
}
assertRecovers("full-custody", allIndices)
// Data cells present but out of order: must decline, as RecoverBlobs
// rejects non-ascending indices.
unordered := slices.Clone(dataIndices)
unordered[0], unordered[1] = unordered[1], unordered[0]
if _, ok := blobsFromDataCells(collect(unordered), unordered); ok {
t.Fatalf("unordered-data: fast path succeeded, expected decline")
}
// A data cell missing (index 63 replaced by an extension cell): the fast
// path must decline, while RecoverBlobs can still reconstruct.
missing := slices.Clone(dataIndices)
missing[DataPerBlob-1] = DataPerBlob // drop data cell 63, add extension cell 64
if _, ok := blobsFromDataCells(collect(missing), missing); ok {
t.Fatalf("missing-data: fast path succeeded, expected decline")
}
if _, err := RecoverBlobs(collect(missing), missing); err != nil {
t.Fatalf("missing-data: RecoverBlobs failed: %v", err)
}
// Too few cells for recovery at all: fast path declines.
short := dataIndices[:DataPerBlob-1]
if _, ok := blobsFromDataCells(collect(short), short); ok {
t.Fatalf("insufficient: fast path succeeded, expected decline")
}
// Malformed extension tails: inputs RecoverBlobs would reject, which the
// fast path must decline rather than accept.
duplicate := append(slices.Clone(dataIndices), DataPerBlob-1) // 63 repeated
if _, ok := blobsFromDataCells(collect(duplicate), duplicate); ok {
t.Fatalf("duplicate-tail: fast path succeeded, expected decline")
}
if _, err := RecoverBlobs(collect(duplicate), duplicate); err == nil {
t.Fatalf("duplicate-tail: RecoverBlobs succeeded, expected error")
}
unorderedTail := append(slices.Clone(dataIndices), 65, 64)
if _, ok := blobsFromDataCells(collect(unorderedTail), unorderedTail); ok {
t.Fatalf("unordered-tail: fast path succeeded, expected decline")
}
outOfRange := append(slices.Clone(dataIndices), CellsPerBlob)
cellsOOR := append(slices.Clone(collect(dataIndices)[:DataPerBlob]), Cell{}) // one blob
if _, ok := blobsFromDataCells(cellsOOR, outOfRange); ok {
t.Fatalf("out-of-range-tail: fast path succeeded, expected decline")
}
// Single blob: the slicing math must hold for blobCount == 1 too.
d1 := newBlobs(t, 1)
single, ok := blobsFromDataCells(d1.cells[:DataPerBlob], dataIndices)
if !ok {
t.Fatalf("single-blob: fast path declined, expected success")
}
if single[0] != d1.blobs[0] {
t.Fatalf("single-blob: reconstructed blob does not match original")
}
// Randomized well-formed tails: the data cells plus a random sorted subset
// of the extension indices must be accepted and agree with RecoverBlobs.
for iter := range 5 {
rng := mrand.New(mrand.NewSource(int64(iter)))
perm := rng.Perm(CellsPerBlob - DataPerBlob)
tail := make([]uint64, rng.Intn(CellsPerBlob-DataPerBlob+1))
for i := range tail {
tail[i] = uint64(DataPerBlob + perm[i])
}
slices.Sort(tail)
assertRecovers("random-tail", append(slices.Clone(dataIndices), tail...))
}
}
func TestCKZGRecoverBlobsUnchecked(t *testing.T) { testRecoverBlobsUnchecked(t, true) }
func TestGoKZGRecoverBlobsUnchecked(t *testing.T) { testRecoverBlobsUnchecked(t, false) }
// testRecoverBlobsUnchecked checks that the unchecked recovery takes the
// KZG-free fast path when the data cells are present and falls back to full
// erasure recovery otherwise, matching the original blobs in both cases.
func testRecoverBlobsUnchecked(t *testing.T, ckzg bool) {
defer switchBackend(t, ckzg)()
const blobCount = 3
d := newBlobs(t, blobCount)
// collect gathers the cells for the given per-blob indices across all blobs.
collect := func(indices []uint64) []Cell {
var cells []Cell
for bi := range blobCount {
for _, idx := range indices {
cells = append(cells, d.cells[bi*CellsPerBlob+int(idx)])
}
}
return cells
}
// assertRecovers checks recovery succeeds, verifies against the cell proofs,
// and matches the original blobs.
assertRecovers := func(name string, indices []uint64) {
t.Helper()
blobs, err := RecoverBlobsUnchecked(collect(indices), indices)
if err != nil {
t.Fatalf("%s: recovery failed: %v", name, err)
}
if err := VerifyCellProofs(blobs, d.commitments, d.proofs); err != nil {
t.Fatalf("%s: recovered blobs failed verification: %v", name, err)
}
for i := range d.blobs {
if blobs[i] != d.blobs[i] {
t.Fatalf("%s: recovered blob %d does not match original", name, i)
}
}
}
// Fast path: exactly the data cells, in canonical order.
dataIndices := make([]uint64, DataPerBlob)
for i := range dataIndices {
dataIndices[i] = uint64(i)
}
assertRecovers("data-only (fast path)", dataIndices)
// Fallback: a non-data subset (data cell 0 swapped for extension cell 64)
// must route through the KZG erasure decode and still reconstruct.
sparse := slices.Clone(dataIndices)
sparse[0] = DataPerBlob // drop data cell 0, add extension cell 64
slices.Sort(sparse)
if _, ok := blobsFromDataCells(collect(sparse), sparse); ok {
t.Fatalf("test setup: expected fast path to decline for the sparse subset")
}
assertRecovers("sparse (fallback)", sparse)
// Insufficient cells: recovery must error, like RecoverBlobs.
short := dataIndices[:DataPerBlob-1]
if _, err := RecoverBlobsUnchecked(collect(short), short); err == nil {
t.Fatalf("insufficient: expected error, got none")
}
}