Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions l1-contracts/src/core/interfaces/IRollup.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ struct PublicInputArgs {
bytes32 previousArchive;
bytes32 endArchive;
bytes32 outHash;
// Inbox rolling-hash chain segment consumed across the proven epoch (AZIP-22 Fast Inbox). Deliberately UNVALIDATED
// until the Fast Inbox flip, when they get checked against per-checkpoint records written at propose; for now they
// are only passed through to the proof's public inputs.
bytes32 previousInboxRollingHash;
bytes32 endInboxRollingHash;
address proverId;
}

Expand Down
203 changes: 122 additions & 81 deletions l1-contracts/src/core/libraries/rollup/EpochProofLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ library EpochProofLib {

Epoch endEpoch = assertAcceptable(_args.start, _args.end);

// Rehash the supplied headers against storage once, here: the public-input assembly below reads the fee
// recipient/value out of them and relies on this call having run.
verifyHeaders(_args.start, _args.end, _args.headers);

// Verify attestations for the last checkpoint in the epoch
Expand Down Expand Up @@ -150,6 +152,11 @@ library EpochProofLib {
* own public inputs used for generating the proof vs the ones assembled
* by this contract when verifying it.
*
* @dev The fee recipient/value public inputs are sourced from the supplied headers, so this entry point rehashes
* them against storage before assembling: an off-chain caller must not walk away with public inputs built from
* unverified fee fields and only discover the mismatch when the on-chain proof reverts. The submit path verifies
* the headers up front and assembles via computeEpochProofPublicInputs to avoid rehashing them twice.
*
* @param _start - The start of the epoch (inclusive)
* @param _end - The end of the epoch (inclusive)
* @param _args - Array of public inputs to the proof (previousArchive, endArchive, endTimestamp, outHash, proverId)
Expand All @@ -163,6 +170,107 @@ library EpochProofLib {
ProposedHeader[] calldata _headers,
bytes calldata _blobPublicInputs
) internal view returns (bytes32[] memory) {
verifyHeaders(_start, _end, _headers);
return computeEpochProofPublicInputs(_start, _end, _args, _headers, _blobPublicInputs);
}

/**
* @notice Verifies committee attestations for the last checkpoint in the epoch before accepting the epoch proof
*
* @dev This verification ensures that the committee has properly validated the final state of the epoch
* before the proof can be accepted. The function validates that:
* 1. The provided attestations match the stored attestation hash for the checkpoint
* 2. The attestations have valid signatures from committee members
* 3. The attestations meet the required threshold (2/3+ of committee)
*
* For escape hatch epochs, attestation verification is skipped since there is no committee
* involvement - only the designated escape hatch proposer can propose blocks.
*
* @dev Errors Thrown:
* - Rollup__InvalidAttestations: Provided attestations don't match stored hash or fail validation
*
* @param _endCheckpointNumber The last checkpoint number in the epoch to verify attestations for
* @param _attestations The committee attestations containing signatures and validator information
*/
function verifyLastCheckpointAttestationsAndOutHash(
uint256 _endCheckpointNumber,
CommitteeAttestations memory _attestations,
bytes32 _outHash
) private {
// Get the stored attestation hash and payload digest for the last checkpoint
CompressedTempCheckpointLog storage checkpointLog = STFLib.getStorageTempCheckpointLog(_endCheckpointNumber);

// Verify that the out hash matches the stored value
// The stored out hash is part of the payloadDigest that was attested to.
require(checkpointLog.outHash == _outHash, Errors.Rollup__InvalidOutHash(checkpointLog.outHash, _outHash));

// Verify that the provided attestations match the stored hash
bytes32 providedAttestationsHash = keccak256(abi.encode(_attestations));
require(providedAttestationsHash == checkpointLog.attestationsHash, Errors.Rollup__InvalidAttestations());

// Get the epoch for the last checkpoint
Epoch epoch = STFLib.getEpochForCheckpoint(_endCheckpointNumber);

// Check if this is an escape hatch epoch - skip attestation verification if so
// since escape hatch blocks are proposed without committee attestations.
// Uses epoch-stable lookup so proof verification uses the escape hatch that was
// active when the epoch started, not whatever is currently configured.
{
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(epoch);
if (address(escapeHatch) != address(0)) {
(bool isOpen,) = escapeHatch.isHatchOpen(epoch);
if (isOpen) {
// Skip attestation verification for escape hatch epochs
return;
}
}
}

ValidatorSelectionLib.verifyAttestations(epoch, _attestations, checkpointLog.payloadDigest);
}

/**
* @notice Rehashes each provided checkpoint header and requires it to match the stored header hash
*
* @param _start The first checkpoint number in the epoch (inclusive)
* @param _end The last checkpoint number in the epoch (inclusive)
* @param _headers The proposed headers for each checkpoint in [_start, _end]
*/
function verifyHeaders(uint256 _start, uint256 _end, ProposedHeader[] calldata _headers) private view {
uint256 numCheckpoints = _end - _start + 1;
require(
_headers.length == numCheckpoints, Errors.Rollup__InvalidCheckpointHeaderCount(numCheckpoints, _headers.length)
);

for (uint256 i = 0; i < numCheckpoints; i++) {
bytes32 expectedHeaderHash = STFLib.getHeaderHash(_start + i);
bytes32 providedHeaderHash = ProposedHeaderLib.hash(_headers[i]);
require(
providedHeaderHash == expectedHeaderHash,
Errors.Rollup__InvalidCheckpointHeader(expectedHeaderHash, providedHeaderHash)
);
}
}

/**
* @notice Assembles the root rollup public inputs, taking the supplied checkpoint headers as already verified
*
* @dev Callers must have rehashed `_headers` against the stored header hashes beforehand, since the fee
* recipient/value public inputs are read straight out of them.
*
* @param _start - The start of the epoch (inclusive)
* @param _end - The end of the epoch (inclusive)
* @param _args - Array of public inputs to the proof (previousArchive, endArchive, endTimestamp, outHash, proverId)
* @param _headers - The proposed checkpoint headers supplying the fee recipient and value for each checkpoint
* @param _blobPublicInputs- The blob public inputs for the proof
*/
function computeEpochProofPublicInputs(
uint256 _start,
uint256 _end,
PublicInputArgs calldata _args,
ProposedHeader[] calldata _headers,
bytes calldata _blobPublicInputs
) private view returns (bytes32[] memory) {
RollupStore storage rollupStore = STFLib.getStorage();

{
Expand Down Expand Up @@ -191,6 +299,8 @@ library EpochProofLib {
// previous_archive_root: Field,
// end_archive_root: Field,
// out_hash: Field,
// previous_inbox_rolling_hash: Field,
// end_inbox_rolling_hash: Field,
// checkpointHeaderHashes: [Field; Constants.MAX_CHECKPOINTS_PER_EPOCH],
// fees: [FeeRecipient; Constants.MAX_CHECKPOINTS_PER_EPOCH],
// chain_id: Field,
Expand All @@ -208,15 +318,21 @@ library EpochProofLib {
publicInputs[1] = _args.endArchive;

publicInputs[2] = _args.outHash;

// Inbox rolling-hash chain segment consumed across the epoch (AZIP-22 Fast Inbox). Deliberately UNVALIDATED
// until the Fast Inbox flip, when they get checked against per-checkpoint records written at propose; for now
// they are only passed through to the proof's public inputs.
publicInputs[3] = _args.previousInboxRollingHash;
publicInputs[4] = _args.endInboxRollingHash;
}

uint256 numCheckpoints = _end - _start + 1;

for (uint256 i = 0; i < numCheckpoints; i++) {
publicInputs[3 + i] = STFLib.getHeaderHash(_start + i);
publicInputs[5 + i] = STFLib.getHeaderHash(_start + i);
}

uint256 offset = 3 + Constants.MAX_CHECKPOINTS_PER_EPOCH;
uint256 offset = 5 + Constants.MAX_CHECKPOINTS_PER_EPOCH;

// Taking recipient/value from the checkpoint headers rather than the prover
// as defense in depth. Slots past numCheckpoints stay zero.
Expand Down Expand Up @@ -276,84 +392,6 @@ library EpochProofLib {
return publicInputs;
}

/**
* @notice Verifies committee attestations for the last checkpoint in the epoch before accepting the epoch proof
*
* @dev This verification ensures that the committee has properly validated the final state of the epoch
* before the proof can be accepted. The function validates that:
* 1. The provided attestations match the stored attestation hash for the checkpoint
* 2. The attestations have valid signatures from committee members
* 3. The attestations meet the required threshold (2/3+ of committee)
*
* For escape hatch epochs, attestation verification is skipped since there is no committee
* involvement - only the designated escape hatch proposer can propose blocks.
*
* @dev Errors Thrown:
* - Rollup__InvalidAttestations: Provided attestations don't match stored hash or fail validation
*
* @param _endCheckpointNumber The last checkpoint number in the epoch to verify attestations for
* @param _attestations The committee attestations containing signatures and validator information
*/
function verifyLastCheckpointAttestationsAndOutHash(
uint256 _endCheckpointNumber,
CommitteeAttestations memory _attestations,
bytes32 _outHash
) private {
// Get the stored attestation hash and payload digest for the last checkpoint
CompressedTempCheckpointLog storage checkpointLog = STFLib.getStorageTempCheckpointLog(_endCheckpointNumber);

// Verify that the out hash matches the stored value
// The stored out hash is part of the payloadDigest that was attested to.
require(checkpointLog.outHash == _outHash, Errors.Rollup__InvalidOutHash(checkpointLog.outHash, _outHash));

// Verify that the provided attestations match the stored hash
bytes32 providedAttestationsHash = keccak256(abi.encode(_attestations));
require(providedAttestationsHash == checkpointLog.attestationsHash, Errors.Rollup__InvalidAttestations());

// Get the epoch for the last checkpoint
Epoch epoch = STFLib.getEpochForCheckpoint(_endCheckpointNumber);

// Check if this is an escape hatch epoch - skip attestation verification if so
// since escape hatch blocks are proposed without committee attestations.
// Uses epoch-stable lookup so proof verification uses the escape hatch that was
// active when the epoch started, not whatever is currently configured.
{
IEscapeHatch escapeHatch = ValidatorSelectionLib.getEscapeHatchForEpoch(epoch);
if (address(escapeHatch) != address(0)) {
(bool isOpen,) = escapeHatch.isHatchOpen(epoch);
if (isOpen) {
// Skip attestation verification for escape hatch epochs
return;
}
}
}

ValidatorSelectionLib.verifyAttestations(epoch, _attestations, checkpointLog.payloadDigest);
}

/**
* @notice Rehashes each provided checkpoint header and requires it to match the stored header hash
*
* @param _start The first checkpoint number in the epoch (inclusive)
* @param _end The last checkpoint number in the epoch (inclusive)
* @param _headers The proposed headers for each checkpoint in [_start, _end]
*/
function verifyHeaders(uint256 _start, uint256 _end, ProposedHeader[] calldata _headers) private view {
uint256 numCheckpoints = _end - _start + 1;
require(
_headers.length == numCheckpoints, Errors.Rollup__InvalidCheckpointHeaderCount(numCheckpoints, _headers.length)
);

for (uint256 i = 0; i < numCheckpoints; i++) {
bytes32 expectedHeaderHash = STFLib.getHeaderHash(_start + i);
bytes32 providedHeaderHash = ProposedHeaderLib.hash(_headers[i]);
require(
providedHeaderHash == expectedHeaderHash,
Errors.Rollup__InvalidCheckpointHeader(expectedHeaderHash, providedHeaderHash)
);
}
}

/**
* @notice Validates that an epoch proof submission meets all acceptance criteria
*
Expand Down Expand Up @@ -421,6 +459,9 @@ library EpochProofLib {
* 2. Assembling the public inputs for the root rollup circuit
* 3. Verifying the validity proof against the assembled public inputs using the configured verifier
*
* @dev Assumes the caller has already verified the supplied checkpoint headers against storage, so assembly skips
* rehashing them.
*
* @dev Errors Thrown:
* - Rollup__InvalidBlobProof: Batched blob proof verification failed
* - Rollup__InvalidProof: validity proof verification failed
Expand All @@ -436,7 +477,7 @@ library EpochProofLib {
BlobLib.validateBatchedBlob(_args.blobInputs);

bytes32[] memory publicInputs =
getEpochProofPublicInputs(_args.start, _args.end, _args.args, _args.headers, _args.blobInputs);
computeEpochProofPublicInputs(_args.start, _args.end, _args.args, _args.headers, _args.blobInputs);

require(rollupStore.config.epochProofVerifier.verify(_args.proof, publicInputs), Errors.Rollup__InvalidProof());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct ProposedHeader {
bytes32 blockHeadersHash;
bytes32 blobsHash;
bytes32 inHash;
bytes32 inboxRollingHash;
bytes32 outHash;
Slot slotNumber;
Timestamp timestamp;
Expand Down Expand Up @@ -56,6 +57,7 @@ library ProposedHeaderLib {
_header.blockHeadersHash,
_header.blobsHash,
_header.inHash,
_header.inboxRollingHash,
_header.outHash,
_header.slotNumber,
Timestamp.unwrap(_header.timestamp).toUint64(),
Expand Down
55 changes: 53 additions & 2 deletions l1-contracts/test/Rollup.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {Registry} from "@aztec/governance/Registry.sol";
import {Inbox} from "@aztec/core/messagebridge/Inbox.sol";
import {Outbox} from "@aztec/core/messagebridge/Outbox.sol";
import {Errors} from "@aztec/core/libraries/Errors.sol";
import {ProposedHeader} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol";
import {ProposedHeader, ProposedHeaderLib} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol";

import {
IRollupCore,
Expand Down Expand Up @@ -874,6 +874,52 @@ contract RollupTest is RollupBase {
assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should be outHash2");
}

// getEpochProofPublicInputs is the view that the prover-publisher calls off-chain to validate its inputs before
// submitting. Because the fee recipient/value public inputs are taken from the supplied headers, the header check
// must run here too - not only on the submit path - so a mismatch is caught before publishing rather than reverting
// on-chain.
function testGetEpochProofPublicInputsVerifiesHeaders() public setUpFor("empty_checkpoint_1") {
_proposeCheckpoint("empty_checkpoint_1", 1);

DecoderBase.Data memory data = load("empty_checkpoint_1").checkpoint;
CheckpointLog memory checkpoint = rollup.getCheckpoint(0);

PublicInputArgs memory args = PublicInputArgs({
previousArchive: checkpoint.archive,
endArchive: data.archive,
outHash: data.header.outHash,
previousInboxRollingHash: 0,
endInboxRollingHash: data.header.inboxRollingHash,
proverId: address(0)
});

ProposedHeader[] memory headers = new ProposedHeader[](1);
headers[0] = proposedHeaders[1];

// With the canonical header, the getter assembles the public inputs, sourcing the fee recipient/value from the
// header.
bytes32[] memory publicInputs = rollup.getEpochProofPublicInputs(1, 1, args, headers, data.batchedBlobInputs);
assertEq(publicInputs.length, Constants.ROOT_ROLLUP_PUBLIC_INPUTS_LENGTH, "Unexpected public inputs length");

uint256 feesOffset = 5 + Constants.MAX_CHECKPOINTS_PER_EPOCH;
assertEq(
publicInputs[feesOffset], bytes32(uint256(uint160(headers[0].coinbase))), "Coinbase not sourced from header"
);
assertEq(
publicInputs[feesOffset + 1], bytes32(headers[0].accumulatedFees), "Accumulated fees not sourced from header"
);

// Tamper a fee field so the header no longer hashes to the stored value; the getter must reject it.
bytes32 expectedHeaderHash = ProposedHeaderLib.hash(headers[0]);
headers[0].accumulatedFees += 1;
bytes32 providedHeaderHash = ProposedHeaderLib.hash(headers[0]);

vm.expectRevert(
abi.encodeWithSelector(Errors.Rollup__InvalidCheckpointHeader.selector, expectedHeaderHash, providedHeaderHash)
);
rollup.getEpochProofPublicInputs(1, 1, args, headers, data.batchedBlobInputs);
}

function _submitEpochProof(
uint256 _start,
uint256 _end,
Expand All @@ -895,7 +941,12 @@ contract RollupTest is RollupBase {
address _prover
) internal {
PublicInputArgs memory args = PublicInputArgs({
previousArchive: _prevArchive, endArchive: _archive, outHash: _outHash, proverId: _prover
previousArchive: _prevArchive,
endArchive: _archive,
outHash: _outHash,
previousInboxRollingHash: 0,
endInboxRollingHash: 0,
proverId: _prover
});

uint256 size = _end - _start + 1;
Expand Down
2 changes: 2 additions & 0 deletions l1-contracts/test/base/DecoderBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ contract DecoderBase is TestBase {
bytes32 feeRecipient;
GasFees gasFees;
bytes32 inHash;
bytes32 inboxRollingHash;
bytes32 lastArchiveRoot;
bytes32 outHash;
uint256 slotNumber;
Expand Down Expand Up @@ -104,6 +105,7 @@ contract DecoderBase is TestBase {
blockHeadersHash: full.checkpoint.header.blockHeadersHash,
blobsHash: full.checkpoint.header.blobsHash,
inHash: full.checkpoint.header.inHash,
inboxRollingHash: full.checkpoint.header.inboxRollingHash,
outHash: full.checkpoint.header.outHash,
slotNumber: Slot.wrap(full.checkpoint.header.slotNumber),
timestamp: Timestamp.wrap(full.checkpoint.header.timestamp),
Expand Down
2 changes: 2 additions & 0 deletions l1-contracts/test/base/RollupBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ contract RollupBase is DecoderBase {
previousArchive: parentCheckpointLog.archive,
endArchive: endFull.checkpoint.archive,
outHash: endFull.checkpoint.header.outHash,
previousInboxRollingHash: 0,
endInboxRollingHash: 0,
proverId: _prover
});

Expand Down
Loading
Loading