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.
- The aggregator computes or obtains a valid recursive proof whose public output is:
- 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.
- 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))
-
The contract verifies the recursive proof successfully because the proof public values contain R.
-
The contract stores:
aggregatedProofs[R] = true;
- The contract emits:
AggregatedProofVerified(R, H_fake);
-
SDK clients watching events may attempt to retrieve blob H_fake from the beacon API.
-
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
Summary
AlignedProofAggregationService.verifySP1andverifyRisc0verify a recursive proof whose public output is a Merkle root, then store that root as verified and emit:AggregatedProofVerified(merkleRoot, blobVersionedHash)However,
blobVersionedHashis 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
Rcan be paired on-chain with an arbitraryblobVersionedHash.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
#2172appears related to EIP-7594 blob sidecar handling, but I did not find a direct duplicate addressing the on-chain binding between the emittedblobVersionedHashand 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:
and emits:
AggregatedProofVerified(merkleRoot, blobVersionedHash);The problem is that
blobVersionedHashis not derived from the verified proof public values and is not checked against the EVM blob context.The contract does not verify that:
It also does not verify that:
Nor does the recursive proof public output appear to include:
This means the on-chain event can claim a root/blob association that the contract never checked.
Violated invariant
For every emitted event:
the protocol should guarantee either:
or the recursive proof statement itself should bind the blob hash:
The current contract-level invariant appears to be only:
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:
The Risc0 path is analogous:
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.
where
H_fakeis not the versioned hash of the blob containing the leaves forR.or:
The contract verifies the recursive proof successfully because the proof public values contain
R.The contract stores:
AggregatedProofVerified(R, H_fake);SDK clients watching events may attempt to retrieve blob
H_fakefrom the beacon API.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:
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:
The narrower issue is that the emitted
blobVersionedHashbecomes part of the protocol’s external evidence surface without being cryptographically bound to the verified root.Why this is reachable
blobVersionedHashis supplied as calldata. The only restriction isonlyAlignedAggregator.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
blobVersionedHashfrom 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:
Second, the recursive proof public values or journal should include the blob hash, leaf count, and aggregation protocol version:
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:
but that should be defense-in-depth, not the only binding.
Suggested formal property
A Foundry invariant could be:
A stronger integration property could be:
A TLA+-style invariant: