From 49c5eaa81ad104127f16466f3c9fbe4eae17b827 Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 6 May 2026 14:18:59 +0800 Subject: [PATCH 1/4] fix(derivation): harden blob verification for PeerDAS sidecars Switch blob authentication from the legacy single-blob VerifyBlobProof path to a commitment round-trip (BlobToCommitment + KZGToVersionedHash match) so derivation keeps working when beacon nodes return EIP-7594 cell proofs (PeerDAS / Osaka) instead of legacy kzg_proofs. The new chain is: blob bytes -> recomputed commitment -> versioned hash, which is then matched against the L1-signed blob hash carried in the type-3 tx. This gives the same soundness as VerifyBlobProof without depending on the beacon-supplied kzg_proof field, which is no longer guaranteed to be a legacy single-blob proof across forks/clients. Also: - Reject malformed beacon responses up front by asserting the decoded blob is exactly BlobSize, so a length mismatch surfaces clearly instead of cascading into a confusing "commitment mismatch" caused by copy()'s silent zero-pad / truncate. - Drop the now-unused ComputeBlobProof call. ParseBatch only consumes Sidecar.Blobs, so computing a fresh proof per blob per batch was pure overhead. Documented how to re-introduce it if a future consumer needs Proofs. Co-authored-by: Cursor --- node/derivation/derivation.go | 40 ++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 8338ed6a6..21f0e603e 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -411,22 +411,52 @@ func (d *Derivation) fetchRollupDataByTxHash(txHash common.Hash, blockNumber uin var commitment kzg4844.Commitment copy(commitment[:], sidecar.KZGCommitment[:]) - var blob Blob b, err := hexutil.Decode(sidecar.Blob) if err != nil { return nil, fmt.Errorf("failed to decode blob %d: %w", i, err) } + // Reject malformed beacon responses up front. copy(blob[:], b) + // silently: + // - zero-pads when len(b) < BlobSize (tail of the + // zero-initialized array stays zero) + // - truncates when len(b) > BlobSize (extra bytes dropped) + // Either case would otherwise surface later as a confusing + // "commitment mismatch" instead of a clear length error. + if len(b) != BlobSize { + return nil, fmt.Errorf("blob %d: unexpected length %d (want %d, hash=%s)", i, len(b), BlobSize, expectedHash.Hex()) + } + var blob Blob copy(blob[:], b) - proof := kzg4844.Proof(sidecar.KZGProof) - if err := VerifyBlobProof(&blob, commitment, proof); err != nil { - return nil, fmt.Errorf("blob %d KZG proof verification failed: %w", i, err) + // Authenticate blob bytes by re-deriving the commitment locally and + // comparing against the beacon-supplied commitment. Combined with the + // versioned-hash match performed when building byHash above, this + // proves: blob bytes -> commitment -> versioned hash matches the + // hash signed on L1. + // + // We deliberately do NOT call VerifyBlobProof on the beacon-supplied + // kzg_proof: after EIP-7594 (PeerDAS / Osaka) submitters may attach + // cell proofs (BlobSidecarVersion1) instead of the legacy single-blob + // proof, and the /eth/v1/beacon/blob_sidecars/{slot} endpoint's + // kzg_proof field is not guaranteed to remain a valid legacy proof + // across forks/clients. The commitment round-trip here gives us the + // same security property without depending on that field. + recomputed, err := kzg4844.BlobToCommitment(blob.KZGBlob()) + if err != nil { + return nil, fmt.Errorf("blob %d: failed to recompute commitment: %w", i, err) + } + if recomputed != commitment { + return nil, fmt.Errorf("blob %d commitment mismatch: blob bytes do not match beacon-supplied commitment (hash=%s)", i, expectedHash.Hex()) } + // Downstream (ParseBatch) only consumes Sidecar.Blobs; Proofs is + // intentionally left empty to avoid an extra ~O(n) KZG op per + // blob per batch on every sync. If a future consumer needs + // Proofs, compute them lazily there or re-introduce + // kzg4844.ComputeBlobProof here. d.logger.Info("Matched blob", "txOrder", i, "beaconIndex", sidecar.Index, "hash", expectedHash.Hex()) blobTxSidecar.Blobs = append(blobTxSidecar.Blobs, *blob.KZGBlob()) blobTxSidecar.Commitments = append(blobTxSidecar.Commitments, commitment) - blobTxSidecar.Proofs = append(blobTxSidecar.Proofs, proof) } d.logger.Info("Blob matching results", "matched", len(blobTxSidecar.Blobs), "expected", len(blobHashes)) From 75bc7b26fdb7111078da2d76ad4a892b3f912f25 Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 6 May 2026 14:38:21 +0800 Subject: [PATCH 2/4] refactor(derivation): extract verifyBlob helper Move the inlined blob authentication logic out of fetchRollupDataByTxHash into a small verifyBlob(blob, expectedHash) helper in beacon.go, mirroring the structure introduced by ethereum-optimism/optimism PR #17725 ("l1-beacon-client: verify blobs using commitment only"). The helper bundles the BlobToCommitment + KZGToVersionedHash + compare chain in one place, so the caller's loop only needs to do: if err := verifyBlob(&blob, expectedHash); err != nil { ... } This is a pure refactor: same authentication path, same security property (blob bytes -> commitment -> versioned hash matches the L1-signed hash), no behavioral change. Replaces the previously dead VerifyBlobProof wrapper, whose name was actively misleading now that we no longer consume any beacon-supplied kzg_proof. Co-authored-by: Cursor --- node/derivation/beacon.go | 27 ++++++++++++++++++-- node/derivation/derivation.go | 47 +++++++++++++---------------------- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/node/derivation/beacon.go b/node/derivation/beacon.go index 50bd0802a..83bfa7e77 100644 --- a/node/derivation/beacon.go +++ b/node/derivation/beacon.go @@ -159,8 +159,31 @@ func KZGToVersionedHash(commitment kzg4844.Commitment) (out common.Hash) { return out } -func VerifyBlobProof(blob *Blob, commitment kzg4844.Commitment, proof kzg4844.Proof) error { - return kzg4844.VerifyBlobProof(blob.KZGBlob(), commitment, proof) +// verifyBlob authenticates a blob against the L1-signed versioned blob hash +// by recomputing the KZG commitment locally and checking +// +// KZGToVersionedHash(BlobToCommitment(blob)) == expectedHash +// +// We deliberately do NOT verify a beacon-supplied kzg_proof. After +// EIP-7594 (PeerDAS / Osaka) the beacon /eth/v1/beacon/blob_sidecars +// endpoint's kzg_proof field is no longer guaranteed to be a legacy +// single-blob proof across forks/clients, and the new +// /eth/v1/beacon/blobs endpoint does not return proofs at all. The +// commitment round-trip gives us the same security property +// (blob bytes -> commitment -> versioned hash matches the L1-signed +// hash) without depending on those fields. +// +// Mirrors ethereum-optimism/optimism PR #17725 (verifyBlob). +func verifyBlob(blob *Blob, expectedHash common.Hash) error { + commitment, err := kzg4844.BlobToCommitment(blob.KZGBlob()) + if err != nil { + return fmt.Errorf("cannot compute KZG commitment for blob: %w", err) + } + got := KZGToVersionedHash(commitment) + if got != expectedHash { + return fmt.Errorf("recomputed blob hash %s does not match expected %s", got.Hex(), expectedHash.Hex()) + } + return nil } // dataAndHashesFromTxs extracts calldata and datahashes from the input transactions and returns them. It diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 21f0e603e..d77f462a4 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -393,23 +393,29 @@ func (d *Derivation) fetchRollupDataByTxHash(txHash common.Hash, blockNumber uin // can assemble the local sidecar in the exact order the L1 tx // declared its blobs. Multi-blob batches are decoded by // concatenating blob bodies in tx order; any reordering here - // would corrupt the resulting zstd stream. + // would corrupt the resulting zstd stream. The map key is + // derived from the beacon-supplied commitment; verifyBlob below + // re-derives the same hash from the actual blob bytes, so a + // malicious beacon cannot forge an entry by lying about the + // commitment. byHash := make(map[common.Hash]*BlobSidecar, len(blobSidecars)) for _, sidecar := range blobSidecars { var commitment kzg4844.Commitment copy(commitment[:], sidecar.KZGCommitment[:]) - versionedHash := KZGToVersionedHash(commitment) - byHash[versionedHash] = sidecar + byHash[KZGToVersionedHash(commitment)] = sidecar } + // Downstream (ParseBatch) only consumes Sidecar.Blobs and + // Sidecar.Commitments; Proofs is intentionally left empty to + // avoid an extra ~O(n) KZG op per blob per batch on every + // sync. If a future consumer needs Proofs, compute them + // lazily there or call kzg4844.ComputeBlobProof here. var blobTxSidecar eth.BlobTxSidecar for i, expectedHash := range blobHashes { sidecar, ok := byHash[expectedHash] if !ok { return nil, fmt.Errorf("blob %d (hash=%s) not found in beacon sidecars", i, expectedHash.Hex()) } - var commitment kzg4844.Commitment - copy(commitment[:], sidecar.KZGCommitment[:]) b, err := hexutil.Decode(sidecar.Blob) if err != nil { @@ -421,39 +427,20 @@ func (d *Derivation) fetchRollupDataByTxHash(txHash common.Hash, blockNumber uin // zero-initialized array stays zero) // - truncates when len(b) > BlobSize (extra bytes dropped) // Either case would otherwise surface later as a confusing - // "commitment mismatch" instead of a clear length error. + // blob-hash mismatch instead of a clear length error. if len(b) != BlobSize { return nil, fmt.Errorf("blob %d: unexpected length %d (want %d, hash=%s)", i, len(b), BlobSize, expectedHash.Hex()) } var blob Blob copy(blob[:], b) - // Authenticate blob bytes by re-deriving the commitment locally and - // comparing against the beacon-supplied commitment. Combined with the - // versioned-hash match performed when building byHash above, this - // proves: blob bytes -> commitment -> versioned hash matches the - // hash signed on L1. - // - // We deliberately do NOT call VerifyBlobProof on the beacon-supplied - // kzg_proof: after EIP-7594 (PeerDAS / Osaka) submitters may attach - // cell proofs (BlobSidecarVersion1) instead of the legacy single-blob - // proof, and the /eth/v1/beacon/blob_sidecars/{slot} endpoint's - // kzg_proof field is not guaranteed to remain a valid legacy proof - // across forks/clients. The commitment round-trip here gives us the - // same security property without depending on that field. - recomputed, err := kzg4844.BlobToCommitment(blob.KZGBlob()) - if err != nil { - return nil, fmt.Errorf("blob %d: failed to recompute commitment: %w", i, err) - } - if recomputed != commitment { - return nil, fmt.Errorf("blob %d commitment mismatch: blob bytes do not match beacon-supplied commitment (hash=%s)", i, expectedHash.Hex()) + if err := verifyBlob(&blob, expectedHash); err != nil { + return nil, fmt.Errorf("blob %d: %w", i, err) } - // Downstream (ParseBatch) only consumes Sidecar.Blobs; Proofs is - // intentionally left empty to avoid an extra ~O(n) KZG op per - // blob per batch on every sync. If a future consumer needs - // Proofs, compute them lazily there or re-introduce - // kzg4844.ComputeBlobProof here. + var commitment kzg4844.Commitment + copy(commitment[:], sidecar.KZGCommitment[:]) + d.logger.Info("Matched blob", "txOrder", i, "beaconIndex", sidecar.Index, "hash", expectedHash.Hex()) blobTxSidecar.Blobs = append(blobTxSidecar.Blobs, *blob.KZGBlob()) blobTxSidecar.Commitments = append(blobTxSidecar.Commitments, commitment) From bb3ed02294fd7548284950e7a6479b763573cde0 Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 6 May 2026 14:40:26 +0800 Subject: [PATCH 3/4] docs(derivation): drop external PR reference from verifyBlob comment The optimism PR pointer was situational context, not durable documentation. The doc comment now stands on its own. Co-authored-by: Cursor --- node/derivation/beacon.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/node/derivation/beacon.go b/node/derivation/beacon.go index 83bfa7e77..ac663241f 100644 --- a/node/derivation/beacon.go +++ b/node/derivation/beacon.go @@ -172,8 +172,6 @@ func KZGToVersionedHash(commitment kzg4844.Commitment) (out common.Hash) { // commitment round-trip gives us the same security property // (blob bytes -> commitment -> versioned hash matches the L1-signed // hash) without depending on those fields. -// -// Mirrors ethereum-optimism/optimism PR #17725 (verifyBlob). func verifyBlob(blob *Blob, expectedHash common.Hash) error { commitment, err := kzg4844.BlobToCommitment(blob.KZGBlob()) if err != nil { From ca9ec0c9ba119d8b3e5c6fe0a73b4430f34f5072 Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 6 May 2026 15:23:13 +0800 Subject: [PATCH 4/4] fix(devnet/layer1): retain full data columns for ~30 days on single-node L1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal-preset PeerDAS L1 used by morph devnet runs as a single beacon node. Two spec-level defaults make this unworkable for any "reset validator and re-derive from L1 genesis" workflow: * CUSTODY_REQUIREMENT=4 / SAMPLES_PER_SLOT=8 — only 4 of the 128 data columns per slot are persisted, which is never enough to reconstruct a blob (needs >= 64). With no peers to gossip the other columns from, those blobs are effectively lost the moment the proposal pipeline finishes. * MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS=4096 — at the minimal preset (8 slot/epoch, 3s/slot) this is ~27h, after which the beacon legitimately prunes columns. A devnet that has been up for >27h cannot serve any historical blob to a freshly reset validator, which manifests as BAD_REQUEST: Insufficient data columns to reconstruct blobs: required 64, but only 0 were found. Set custody/samples to the full 128 so the lone supernode actually keeps every column it produces, and bump the retention window to ~30 days (110000 epochs * 24s) so derivation can backfill from genesis throughout normal devnet lifetimes. lighthouse's --supernode flag is now redundant-but-aligned with the spec rather than silently fighting it. Co-authored-by: Cursor --- ops/docker/layer1/configs/values.env.template | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ops/docker/layer1/configs/values.env.template b/ops/docker/layer1/configs/values.env.template index 52a3ed168..6adca9511 100644 --- a/ops/docker/layer1/configs/values.env.template +++ b/ops/docker/layer1/configs/values.env.template @@ -48,8 +48,13 @@ export VIEW_FREEZE_CUTOFF_BPS=7500 export INCLUSION_LIST_SUBMISSION_DUE_BPS=6667 export PROPOSER_INCLUSION_LIST_CUTOFF_BPS=9167 export DATA_COLUMN_SIDECAR_SUBNET_COUNT=128 -export SAMPLES_PER_SLOT=8 -export CUSTODY_REQUIREMENT=4 +# Single-node devnet: every node IS the entire network, so it must +# custody all 128 columns and sample all 128 each slot. Without this, +# only CUSTODY_REQUIREMENT (default 4) columns are persisted, which is +# never enough to reconstruct blobs (need 64/128) and any historical +# blob retrieval (e.g. validator re-deriving from L1 genesis) fails. +export SAMPLES_PER_SLOT=128 +export CUSTODY_REQUIREMENT=128 export MAX_BLOBS_PER_BLOCK_ELECTRA=9 export TARGET_BLOBS_PER_BLOCK_ELECTRA=6 export MAX_REQUEST_BLOCKS_DENEB=128 @@ -81,5 +86,9 @@ export BPO_5_EPOCH=18446744073709551615 export BPO_5_MAX_BLOBS=0 export BPO_5_TARGET_BLOBS=0 export BPO_5_BASE_FEE_UPDATE_FRACTION=0 -export MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS=4096 +# Bumped from spec default 4096 (~27h on a 3s-slot/8-slot-per-epoch +# minimal preset) to ~30 days, so a freshly reset validator can always +# re-derive from L1 genesis without hitting "0 data columns found" +# pruning errors. 110000 epochs * 24s/epoch ≈ 30.5 days. +export MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS=110000 export MIN_EPOCHS_FOR_BLOCK_REQUESTS=33024