Skip to content

fix(aggregation): bind emitted blobVersionedHash to the verified Merkle root #2285

@doomhammerhell

Description

@doomhammerhell

Summary

AlignedProofAggregationService.verifySP1 and verifyRisc0 verify a recursive proof whose public output is a Merkle root, then store that root as verified and emit:

AggregatedProofVerified(merkleRoot, blobVersionedHash)

However, blobVersionedHash is supplied as ordinary calldata by the authorized aligned aggregator. The contract does not appear to verify that this value corresponds to a blob included in the same transaction, nor that the blob reconstructs the verified Merkle root.

As a result, a valid recursive proof for root R can be paired on-chain with an arbitrary blobVersionedHash.

The root verification itself may still be valid. The issue is that the emitted evidence object binds a verified root to a data-availability object that was not verified by the contract.

Component

AlignedProofAggregationService, aggregation-mode backend, SDK event/blob retrieval.

Related issues / PRs checked

I noticed that #2172 appears related to EIP-7594 blob sidecar handling, but I did not find a direct duplicate addressing the on-chain binding between the emitted blobVersionedHash and the verified Merkle root.

If this association is intentionally trusted to the aligned aggregator rather than enforced on-chain or inside the recursive proof statement, it would be useful to document that trust boundary explicitly.

Description

The aggregation contract verifies a recursive SP1 or Risc0 proof whose public output is decoded as a Merkle root.

After successful proof verification, the contract marks:

aggregatedProofs[merkleRoot] = true;

and emits:

AggregatedProofVerified(merkleRoot, blobVersionedHash);

The problem is that blobVersionedHash is not derived from the verified proof public values and is not checked against the EVM blob context.

The contract does not verify that:

blobVersionedHash == blobhash(0)

It also does not verify that:

MerkleRoot(DecodeAlignedAggregationBlob(blob)) == merkleRoot

Nor does the recursive proof public output appear to include:

blobVersionedHash,
leaf_count,
aggregation protocol version,
or blob/data-availability commitment.

This means the on-chain event can claim a root/blob association that the contract never checked.

Violated invariant

For every emitted event:

AggregatedProofVerified(root, blobVersionedHash)

the protocol should guarantee either:

There exists a blob B in the same transaction such that:

  VersionedHash(B) = blobVersionedHash
  and
  MerkleRoot(DecodeAlignedAggregationBlob(B)) = root

or the recursive proof statement itself should bind the blob hash:

VerifyRecursiveProof(
  public_values = (
    aggregation_protocol_version,
    root,
    blobVersionedHash,
    leaf_count,
    ...
  )
) = true

The current contract-level invariant appears to be only:

VerifyRecursiveProof(public_values = root) = true

That is weaker than the evidence emitted by the event.

Technical root cause

In the SP1 path, the contract decodes only a Merkle root from the public values:

function verifySP1(
    bytes32 blobVersionedHash,
    bytes calldata sp1PublicValues,
    bytes calldata sp1ProofBytes
)
    public
    onlyAlignedAggregator
{
    (bytes32 merkleRoot) = abi.decode(sp1PublicValues, (bytes32));

    ISP1Verifier(sp1VerifierAddress).verifyProof(
        sp1AggregatorProgramVKHash,
        sp1PublicValues,
        sp1ProofBytes
    );

    aggregatedProofs[merkleRoot] = true;
    emit AggregatedProofVerified(merkleRoot, blobVersionedHash);
}

The Risc0 path is analogous:

function verifyRisc0(
    bytes32 blobVersionedHash,
    bytes calldata risc0ReceiptSeal,
    bytes calldata risc0JournalBytes
)
    public
    onlyAlignedAggregator
{
    (bytes32 merkleRoot) = abi.decode(risc0JournalBytes, (bytes32));

    IRiscZeroVerifier(risc0VerifierAddress).verify(
        risc0ReceiptSeal,
        risc0ImageId,
        sha256(risc0JournalBytes)
    );

    aggregatedProofs[merkleRoot] = true;
    emit AggregatedProofVerified(merkleRoot, blobVersionedHash);
}

The backend appears to construct a blob and pass the computed hash correctly during the normal path, but the contract does not enforce that relationship. A correct happy path is not the same as an invariant, because apparently software still insists on making us repeat this in every protocol review.

Execution trace

Assume the aligned aggregator key is compromised, misconfigured, or running faulty software. This is the boundary where on-chain verification should ideally prevent inconsistent evidence from being emitted.

  1. The aggregator computes or obtains a valid recursive proof whose public output is:
merkleRoot = R
  1. The aggregator chooses an arbitrary value:
blobVersionedHash = H_fake

where H_fake is not the versioned hash of the blob containing the leaves for R.

  1. The aggregator calls:
verifySP1(H_fake, abi.encode(R), valid_sp1_proof_for_R)

or:

verifyRisc0(H_fake, valid_risc0_seal_for_R, abi.encode(R))
  1. The contract verifies the recursive proof successfully because the proof public values contain R.

  2. The contract stores:

aggregatedProofs[R] = true;
  1. The contract emits:
AggregatedProofVerified(R, H_fake);
  1. SDK clients watching events may attempt to retrieve blob H_fake from the beacon API.

  2. The blob may be absent, unrelated, or unable to reconstruct R.

The event now exposes an unverified root/blob association.

Minimal PoC

A minimal Foundry-style test can use a mock SP1/Risc0 verifier that accepts the proof:

function test_verifySP1_accepts_arbitrary_blob_hash() public {
    bytes32 root = keccak256("valid-root");
    bytes32 fakeBlobHash = keccak256("not-the-transaction-blob");

    vm.prank(alignedAggregator);
    service.verifySP1(fakeBlobHash, abi.encode(root), hex"01");

    assertTrue(service.aggregatedProofs(root));

    // Current behavior:
    // AggregatedProofVerified(root, fakeBlobHash) is emitted.
    //
    // Expected behavior after mitigation:
    // Revert unless fakeBlobHash == blobhash(0),
    // or unless fakeBlobHash is included in the recursive proof public values.
}

The test does not need to break SP1 or Risc0 verification. The issue is that the blob hash is outside the verified statement.

Impact

The impact is a data-availability and evidence-consistency failure.

The contract can emit an association between a verified Merkle root and an arbitrary blob hash. Event-based clients may fail to recover the leaves for a verified aggregate, or may observe a root/blob association that was never actually verified.

This does not necessarily invalidate:

aggregatedProofs[root] = true

The narrower issue is that the emitted blobVersionedHash becomes part of the protocol’s external evidence surface without being cryptographically bound to the verified root.

Why this is reachable

blobVersionedHash is supplied as calldata. The only restriction is onlyAlignedAggregator.

The backend may currently pass the correct value, but the contract does not enforce correctness. A compromised aggregator key, incorrect manual transaction, deployment/configuration bug, or malformed backend path can produce inconsistent events.

This matters because the SDK consumes blobVersionedHash from the event for blob retrieval. Therefore, the event is not merely cosmetic logging. It is part of the evidence interface exposed to clients.

Suggested mitigation

A stronger design would bind the blob hash at both the EVM and proof-statement levels.

First, the contract can require the calldata hash to match the transaction blob hash:

bytes32 txBlobHash;
assembly {
    txBlobHash := blobhash(0)
}

require(txBlobHash != bytes32(0), "missing blob");
require(blobVersionedHash == txBlobHash, "blob hash mismatch");

Second, the recursive proof public values or journal should include the blob hash, leaf count, and aggregation protocol version:

public_values = (
  aggregation_protocol_version,
  merkle_root,
  blob_versioned_hash,
  leaf_count
)

Then the contract should emit only values that were either checked against the transaction blob context or included in the verified proof statement.

SDK-side reconstruction checks are also useful:

MerkleRoot(DecodeBlob(fetched_blob)) == emitted_merkle_root

but that should be defense-in-depth, not the only binding.

Suggested formal property

A Foundry invariant could be:

// For every AggregatedProofVerified(root, h) event emitted in transaction T:
//
// h == blobhash(0) in T
// and h != bytes32(0)

A stronger integration property could be:

proptest! {
    #[test]
    fn emitted_blob_reconstructs_emitted_root(leaves in arbitrary_aggregation_leaves()) {
        let (blob, h) = construct_blob(leaves.clone());
        let root = merkle_root(leaves);

        let event = submit_aggregate_proof(root, h, blob);

        let fetched = fetch_blob(event.blob_versioned_hash);

        prop_assert_eq!(
            merkle_root(decode_blob(fetched)),
            event.merkle_root
        );
    }
}

A TLA+-style invariant:

VerifiedAggregationEventsAreDataAvailable ==
  ∀ e ∈ AggregatedProofVerifiedEvents:
    ∃ b ∈ TxBlobs[e.tx]:
      VersionedHash(b) = e.blobVersionedHash ∧
      MerkleRoot(DecodeBlob(b)) = e.merkleRoot

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions