Skip to content

Commit 2d67039

Browse files
authored
Merge pull request #553 from AztecProtocol/cb/port-field-range-next
feat: reject out-of-range checkpoint header fields at propose (port of #24199 to next)
2 parents 145d926 + e230cf2 commit 2d67039

6 files changed

Lines changed: 260 additions & 2 deletions

File tree

l1-contracts/scripts/test_rollup_upgrade.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ if [[ -z "$registry_address" || "$registry_address" == "null" ]]; then
5151
fi
5252

5353
echo "=== Testing run_rollup_upgrade.sh ==="
54-
# Use a different genesis to get a different rollup version
55-
export GENESIS_ARCHIVE_ROOT="0x$(openssl rand -hex 32)"
54+
# Use a different genesis to get a different rollup version. Only 31 random bytes (top byte zero) so the value is
55+
# always below the BN254 scalar field modulus; the rollup rejects a genesis archive root >= the field modulus.
56+
export GENESIS_ARCHIVE_ROOT="0x00$(openssl rand -hex 31)"
5657

5758
./scripts/run_rollup_upgrade.sh "$registry_address"
5859

l1-contracts/src/core/libraries/Errors.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ library Errors {
9898
error Rollup__NoBlobsInCheckpoint();
9999
error Rollup__CannotInvalidateEscapeHatch();
100100
error Rollup__InvalidEscapeHatchProposer(address expected, address actual);
101+
error Rollup__FieldElementOutOfRange(bytes32 value);
101102

102103
// EscapeHatch
103104
error EscapeHatch__AlreadyInCandidateSet(address candidate);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2024 Aztec Labs.
3+
pragma solidity >=0.8.27;
4+
5+
import {Constants} from "@aztec/core/libraries/ConstantsGen.sol";
6+
import {Errors} from "@aztec/core/libraries/Errors.sol";
7+
8+
/**
9+
* @title FieldLib
10+
* @author Aztec Labs
11+
* @notice Helpers for validating that values stored on L1 are valid BN254 scalar field elements.
12+
* @dev Off-chain components decode several L1 storage slots into `Fr`. A value `>=` the field modulus throws on
13+
* conversion and would brick honest archivers' L1 sync, so such values are rejected at write time.
14+
*/
15+
library FieldLib {
16+
/// @notice Reverts with `Rollup__FieldElementOutOfRange` unless `_value` is a valid field element (`< Constants.P`).
17+
function requireValidFieldElement(bytes32 _value) internal pure {
18+
require(uint256(_value) < Constants.P, Errors.Rollup__FieldElementOutOfRange(_value));
19+
}
20+
}

l1-contracts/src/core/libraries/rollup/ProposeLib.sol

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {Errors} from "@aztec/core/libraries/Errors.sol";
1212
import {CommitteeAttestations} from "@aztec/core/libraries/rollup/AttestationLib.sol";
1313
import {CoordinationSignatureLib} from "@aztec/core/libraries/rollup/CoordinationSignatureLib.sol";
1414
import {OracleInput, FeeLib, ManaMinFeeComponents} from "@aztec/core/libraries/rollup/FeeLib.sol";
15+
import {FieldLib} from "@aztec/core/libraries/rollup/FieldLib.sol";
1516
import {ValidatorSelectionLib} from "@aztec/core/libraries/rollup/ValidatorSelectionLib.sol";
1617
import {Timestamp, Slot, Epoch, TimeLib} from "@aztec/core/libraries/TimeLib.sol";
1718
import {CompressedSlot, CompressedTimeMath} from "@aztec/shared/libraries/CompressedTimeMath.sol";
@@ -125,6 +126,7 @@ library ProposeLib {
125126
* - Proposer signature is valid for designated slot proposer:
126127
* Errors.ValidatorSelection__MissingProposerSignature
127128
* - Inbox hash matches expected value: Errors.Rollup__InvalidInHash
129+
* - Archive root is within the scalar field: Errors.Rollup__FieldElementOutOfRange
128130
*
129131
* Validations NOT performed:
130132
* - Committee attestations (only proposer signature verified)
@@ -184,6 +186,10 @@ library ProposeLib {
184186

185187
v.header = _args.header;
186188

189+
// The new checkpoint archive root is not part of the header, so it is range-checked here rather than in
190+
// validateHeader.
191+
FieldLib.requireValidFieldElement(_args.archive);
192+
187193
// Compute header hash for computing the payload digest
188194
v.headerHash = ProposedHeaderLib.hash(v.header);
189195

@@ -305,6 +311,7 @@ library ProposeLib {
305311
* for proposers to check header validity before submitting transactions
306312
*
307313
* Header validations performed:
314+
* - Fr-encoded header fields are within the scalar field: Errors.Rollup__FieldElementOutOfRange
308315
* - Coinbase address is non-zero: Errors.Rollup__InvalidCoinbase
309316
* - Mana usage within limits: Errors.Rollup__ManaLimitExceeded
310317
* - Builds on correct parent checkpoint (archive root check): Errors.Rollup__InvalidArchive
@@ -319,6 +326,12 @@ library ProposeLib {
319326
* @param _args Validation arguments including header, digest, mana min fee, and flags
320327
*/
321328
function validateHeader(ValidateHeaderArgs memory _args) internal view {
329+
// Check that header fields that map to an Fr are within range.
330+
FieldLib.requireValidFieldElement(_args.header.blockHeadersHash);
331+
FieldLib.requireValidFieldElement(_args.header.outHash);
332+
FieldLib.requireValidFieldElement(_args.header.feeRecipient);
333+
FieldLib.requireValidFieldElement(bytes32(_args.header.accumulatedFees));
334+
322335
require(_args.header.coinbase != address(0), Errors.Rollup__InvalidCoinbase());
323336
require(_args.header.totalManaUsed <= FeeLib.getManaLimit(), Errors.Rollup__ManaLimitExceeded());
324337

l1-contracts/src/core/libraries/rollup/STFLib.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import {CompressedFeeHeader, FeeHeaderLib, FeeHeader} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol";
1212
import {ChainTipsLib, CompressedChainTips} from "@aztec/core/libraries/compressed-data/Tips.sol";
1313
import {Errors} from "@aztec/core/libraries/Errors.sol";
14+
import {FieldLib} from "@aztec/core/libraries/rollup/FieldLib.sol";
1415
import {Timestamp, Slot, Epoch, TimeLib} from "@aztec/core/libraries/TimeLib.sol";
1516
import {CompressedSlot, CompressedTimeMath} from "@aztec/shared/libraries/CompressedTimeMath.sol";
1617

@@ -107,6 +108,9 @@ library STFLib {
107108
rollupStore.config.vkTreeRoot = _genesisState.vkTreeRoot;
108109
rollupStore.config.protocolContractsHash = _genesisState.protocolContractsHash;
109110

111+
// The genesis archive root is decoded as an Fr off chain and propagates into the first header's lastArchiveRoot,
112+
// so it must be a valid field element.
113+
FieldLib.requireValidFieldElement(_genesisState.genesisArchiveRoot);
110114
rollupStore.archives[0] = _genesisState.genesisArchiveRoot;
111115
}
112116

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2024 Aztec Labs.
3+
pragma solidity >=0.8.27;
4+
5+
import {DecoderBase} from "./base/DecoderBase.sol";
6+
import {RollupBase, IInstance} from "./base/RollupBase.sol";
7+
8+
import {Constants} from "@aztec/core/libraries/ConstantsGen.sol";
9+
import {Errors} from "@aztec/core/libraries/Errors.sol";
10+
import {SafeCast} from "@oz/utils/math/SafeCast.sol";
11+
12+
import {Inbox} from "@aztec/core/messagebridge/Inbox.sol";
13+
import {TestConstants} from "./harnesses/TestConstants.sol";
14+
import {RollupBuilder} from "./builder/RollupBuilder.sol";
15+
import {EthValue, GenesisState} from "@aztec/core/interfaces/IRollup.sol";
16+
17+
import {ProposeArgs, OracleInput, ProposeLib} from "@aztec/core/libraries/rollup/ProposeLib.sol";
18+
import {ProposedHeader} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol";
19+
import {Timestamp, Slot, TimeLib} from "@aztec/core/libraries/TimeLib.sol";
20+
import {CommitteeAttestations} from "@aztec/core/libraries/rollup/AttestationLib.sol";
21+
import {Signature} from "@aztec/shared/libraries/SignatureLib.sol";
22+
import {AttestationLibHelper} from "@test/helper_libraries/AttestationLibHelper.sol";
23+
24+
// solhint-disable comprehensive-interface
25+
26+
/**
27+
* @notice Exercises the field-range checks added to ProposeLib.
28+
*
29+
* A malicious proposer could previously store header or archive field values that are >= the BN254 scalar field
30+
* modulus on L1. Off-chain archivers decode those slots into `Fr`, and an out-of-range value bricks their L1 sync.
31+
* The checks reject such values at propose time; these tests cover the boundary (`P`), the extreme
32+
* (`type(uint256).max`), and that an otherwise-valid header with every checked field at `P - 1` still goes through.
33+
*/
34+
contract RollupFieldRangeTest is RollupBase {
35+
uint256 internal constant FIELD_MAX = Constants.P - 1;
36+
37+
string internal constant FIXTURE = "mixed_checkpoint_1";
38+
39+
uint256 internal SLOT_DURATION;
40+
41+
modifier setUp() {
42+
{
43+
DecoderBase.Full memory full = load(FIXTURE);
44+
uint256 slotNumber = Slot.unwrap(full.checkpoint.header.slotNumber);
45+
uint256 initialTime = Timestamp.unwrap(full.checkpoint.header.timestamp) - slotNumber * SLOT_DURATION;
46+
vm.warp(initialTime);
47+
}
48+
49+
RollupBuilder builder =
50+
new RollupBuilder(address(this)).setTargetCommitteeSize(0).setProvingCostPerMana(EthValue.wrap(1000));
51+
builder.deploy();
52+
53+
rollup = IInstance(address(builder.getConfig().rollup));
54+
inbox = Inbox(address(rollup.getInbox()));
55+
_;
56+
}
57+
58+
constructor() {
59+
TimeLib.initialize(
60+
block.timestamp,
61+
TestConstants.AZTEC_SLOT_DURATION,
62+
TestConstants.AZTEC_EPOCH_DURATION,
63+
TestConstants.AZTEC_PROOF_SUBMISSION_EPOCHS
64+
);
65+
SLOT_DURATION = TestConstants.AZTEC_SLOT_DURATION;
66+
}
67+
68+
// ----- archive (checked in propose, before validateHeader) -----
69+
70+
function testRevertsArchiveAtModulus() public setUp {
71+
_expectFieldOutOfRange(_baseArgs(Constants.P, false), bytes32(Constants.P));
72+
}
73+
74+
function testRevertsArchiveAtUintMax() public setUp {
75+
_expectFieldOutOfRange(_baseArgs(type(uint256).max, false), bytes32(type(uint256).max));
76+
}
77+
78+
// ----- blockHeadersHash -----
79+
80+
function testRevertsBlockHeadersHashAtModulus() public setUp {
81+
ProposeArgs memory args = _baseArgs(0, true);
82+
args.header.blockHeadersHash = bytes32(Constants.P);
83+
_expectFieldOutOfRange(args, bytes32(Constants.P));
84+
}
85+
86+
function testRevertsBlockHeadersHashAtUintMax() public setUp {
87+
ProposeArgs memory args = _baseArgs(0, true);
88+
args.header.blockHeadersHash = bytes32(type(uint256).max);
89+
_expectFieldOutOfRange(args, bytes32(type(uint256).max));
90+
}
91+
92+
// ----- outHash -----
93+
94+
function testRevertsOutHashAtModulus() public setUp {
95+
ProposeArgs memory args = _baseArgs(0, true);
96+
args.header.outHash = bytes32(Constants.P);
97+
_expectFieldOutOfRange(args, bytes32(Constants.P));
98+
}
99+
100+
function testRevertsOutHashAtUintMax() public setUp {
101+
ProposeArgs memory args = _baseArgs(0, true);
102+
args.header.outHash = bytes32(type(uint256).max);
103+
_expectFieldOutOfRange(args, bytes32(type(uint256).max));
104+
}
105+
106+
// ----- feeRecipient -----
107+
108+
function testRevertsFeeRecipientAtModulus() public setUp {
109+
ProposeArgs memory args = _baseArgs(0, true);
110+
args.header.feeRecipient = bytes32(Constants.P);
111+
_expectFieldOutOfRange(args, bytes32(Constants.P));
112+
}
113+
114+
function testRevertsFeeRecipientAtUintMax() public setUp {
115+
ProposeArgs memory args = _baseArgs(0, true);
116+
args.header.feeRecipient = bytes32(type(uint256).max);
117+
_expectFieldOutOfRange(args, bytes32(type(uint256).max));
118+
}
119+
120+
// ----- accumulatedFees -----
121+
122+
function testRevertsAccumulatedFeesAtModulus() public setUp {
123+
ProposeArgs memory args = _baseArgs(0, true);
124+
args.header.accumulatedFees = Constants.P;
125+
_expectFieldOutOfRange(args, bytes32(Constants.P));
126+
}
127+
128+
function testRevertsAccumulatedFeesAtUintMax() public setUp {
129+
ProposeArgs memory args = _baseArgs(0, true);
130+
args.header.accumulatedFees = type(uint256).max;
131+
_expectFieldOutOfRange(args, bytes32(type(uint256).max));
132+
}
133+
134+
// ----- genesis archive root (checked in STFLib.initialize at deploy time) -----
135+
136+
function testRevertsGenesisArchiveRootOutOfRange() public {
137+
GenesisState memory genesis = TestConstants.getGenesisState();
138+
genesis.genesisArchiveRoot = bytes32(Constants.P);
139+
140+
RollupBuilder builder = new RollupBuilder(address(this)).setTargetCommitteeSize(0).setGenesisState(genesis);
141+
142+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__FieldElementOutOfRange.selector, bytes32(Constants.P)));
143+
builder.deploy();
144+
}
145+
146+
// ----- happy path: every checked field at P - 1 must not trip the range check -----
147+
148+
/**
149+
* @notice A fully valid propose where each checked field is at the largest in-range value (`P - 1`) must succeed,
150+
* proving the boundary is exclusive and the checks do not reject legitimate field elements.
151+
*/
152+
function testAcceptsAllFieldsAtModulusMinusOne() public setUp {
153+
DecoderBase.Full memory full = load(FIXTURE);
154+
ProposedHeader memory header = full.checkpoint.header;
155+
156+
// Pin the header to a valid slot/fee/inbox/coinbase configuration so the only remaining question is whether the
157+
// range checks accept the boundary values below.
158+
Slot slotNumber = Slot.wrap(1);
159+
Timestamp ts = rollup.getTimestampForSlot(slotNumber);
160+
header.timestamp = ts;
161+
header.slotNumber = slotNumber;
162+
header.coinbase = address(bytes20("sequencer"));
163+
164+
vm.warp(max(block.timestamp, Timestamp.unwrap(ts)));
165+
166+
_populateInbox(full.populate.sender, full.populate.recipient, full.populate.l1ToL2Content);
167+
header.inHash = rollup.getInbox().getRoot(full.checkpoint.checkpointNumber);
168+
header.gasFees.feePerL2Gas = SafeCast.toUint128(rollup.getManaMinFeeAt(ts, true));
169+
170+
// Every field the range check guards, set to the maximal in-range value.
171+
header.blockHeadersHash = bytes32(FIELD_MAX);
172+
header.outHash = bytes32(FIELD_MAX);
173+
header.feeRecipient = bytes32(FIELD_MAX);
174+
header.accumulatedFees = FIELD_MAX;
175+
176+
vm.blobhashes(this.getBlobHashes(full.checkpoint.blobCommitments));
177+
178+
ProposeArgs memory args = ProposeArgs({header: header, archive: bytes32(FIELD_MAX), oracleInput: OracleInput(0)});
179+
180+
rollup.propose(
181+
args,
182+
AttestationLibHelper.packAttestations(attestations),
183+
signers,
184+
attestationsAndSignersSignature,
185+
full.checkpoint.blobCommitments
186+
);
187+
188+
assertEq(rollup.archive(), args.archive, "archive at P - 1 should have been stored");
189+
}
190+
191+
/// @dev Builds propose args for the boundary tests with a valid slot/timestamp but skipped blob check.
192+
function _baseArgs(uint256 _archive, bool _useFixtureArchive) internal returns (ProposeArgs memory) {
193+
DecoderBase.Full memory full = load(FIXTURE);
194+
ProposedHeader memory header = full.checkpoint.header;
195+
196+
Slot slotNumber = Slot.wrap(1);
197+
Timestamp ts = rollup.getTimestampForSlot(slotNumber);
198+
header.timestamp = ts;
199+
header.slotNumber = slotNumber;
200+
201+
vm.warp(max(block.timestamp, Timestamp.unwrap(ts)));
202+
skipBlobCheck(address(rollup));
203+
204+
bytes32 archive = _useFixtureArchive ? full.checkpoint.archive : bytes32(_archive);
205+
return ProposeArgs({header: header, archive: archive, oracleInput: OracleInput(0)});
206+
}
207+
208+
function _expectFieldOutOfRange(ProposeArgs memory _args, bytes32 _value) internal {
209+
// Resolve everything that needs cheatcodes (loading the fixture, packing attestations) BEFORE arming
210+
// expectRevert, otherwise those cheatcode calls are mistaken for the call under test.
211+
bytes memory blobCommitments = load(FIXTURE).checkpoint.blobCommitments;
212+
CommitteeAttestations memory packed = AttestationLibHelper.packAttestations(attestations);
213+
address[] memory localSigners = signers;
214+
Signature memory sig = attestationsAndSignersSignature;
215+
216+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__FieldElementOutOfRange.selector, _value));
217+
rollup.propose(_args, packed, localSigners, sig, blobCommitments);
218+
}
219+
}

0 commit comments

Comments
 (0)