Skip to content

Commit 34134bb

Browse files
authored
feat: merge-train/spartan (#23714)
BEGIN_COMMIT_OVERRIDE feat(stdlib): make empty ChonkProof hold an empty array (#23712) test(e2e): l2-to-l1 mbps and anchor to checkpointed chain (#23717) fix(sequencer): atomically query current and target slots (#23716) test(e2e): restore both malicious nodes to honest in `invalidate_block` (#23720) test: exclude forge reserved addresses from setSlasher fuzz inputs (#23721) END_COMMIT_OVERRIDE
2 parents 9a9810a + 6a3e70d commit 34134bb

10 files changed

Lines changed: 194 additions & 25 deletions

File tree

l1-contracts/test/staking/setSlasher.t.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ contract SetSlasherTest is StakingBase {
1919
/// queueSetSlasher accepts it. Tests that want to exercise the uninitialized-slasher
2020
/// guard call queueSetSlasher directly without mocking.
2121
function _mockInitializedSlasher(address _slasher) internal {
22+
// Foundry intercepts every call to the console address and dispatches it to its console
23+
// handler, which bypasses vm.mockCall. A fuzzed slasher equal to that address therefore makes
24+
// the PROPOSER() lookup revert with "unknown selector for ConsoleCalls" instead of returning
25+
// the mocked value, so exclude the reserved forge addresses.
26+
assumeNotForgeAddress(_slasher);
2227
vm.mockCall(_slasher, abi.encodeWithSignature("PROPOSER()"), abi.encode(address(0xBEEF)));
2328
}
2429

@@ -140,6 +145,7 @@ contract SetSlasherTest is StakingBase {
140145
// Slasher.initializeProposer is permissionless and an attacker could claim the proposer
141146
// role during the 60-day delay.
142147
address owner = _owner(); // cache before expectRevert to avoid consuming it
148+
assumeNotForgeAddress(_newSlasher);
143149
vm.mockCall(_newSlasher, abi.encodeWithSignature("PROPOSER()"), abi.encode(address(0)));
144150

145151
vm.prank(owner);

yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
computeL2ToL1MembershipWitness,
1414
getL2ToL1MessageLeafId,
1515
} from '@aztec/stdlib/messaging';
16-
import type { TxHash } from '@aztec/stdlib/tx';
16+
import { type TxHash, TxStatus } from '@aztec/stdlib/tx';
1717

1818
import { jest } from '@jest/globals';
1919
import { type Hex, decodeEventLog } from 'viem';
@@ -67,15 +67,18 @@ describe('e2e_cross_chain_messaging l2_to_l1', () => {
6767
let contract: TestContract;
6868

6969
beforeAll(async () => {
70-
await t.setup({ ...PIPELINING_SETUP_OPTS });
70+
await t.setup({ ...PIPELINING_SETUP_OPTS }, { syncChainTip: 'checkpointed' });
7171

7272
({ crossChainTestHarness, aztecNode, aztecNodeAdmin, wallet, user1Address, rollup, outbox } = t);
7373

7474
msgSender = EthAddress.fromString(t.deployL1ContractsValues.l1Client.account.address);
7575

7676
version = BigInt(await rollup.getVersion());
7777

78-
({ contract } = await TestContract.deploy(wallet).send({ from: user1Address }));
78+
({ contract } = await TestContract.deploy(wallet).send({
79+
from: user1Address,
80+
wait: { waitForStatus: TxStatus.CHECKPOINTED },
81+
}));
7982
});
8083

8184
afterAll(async () => {
@@ -250,7 +253,64 @@ describe('e2e_cross_chain_messaging l2_to_l1', () => {
250253
}
251254
});
252255

253-
// TODO(#17027): Add tests for multiple blocks per checkpoint.
256+
// Two txs, each emitting one L2-to-L1 message, packed into separate blocks of a single checkpoint.
257+
// This exercises the checkpoint level of the L2-to-L1 message tree (the block out hashes within a
258+
// checkpoint), which the single-block-per-checkpoint cases above never reach. See #17027.
259+
it('2 txs each with a message, in different blocks of the same checkpoint', async () => {
260+
const recipient = msgSender;
261+
const contents = [Fr.random(), Fr.random()];
262+
const messages = contents.map(content => makeL2ToL1Message(recipient, content));
263+
264+
// Enable multiple-blocks-per-checkpoint: enforce the timetable so the sequencer splits the slot
265+
// into per-block sub-slots, cap each block at a single tx, and require (and accept at most) two
266+
// blocks before publishing the checkpoint. With the two txs below this yields one checkpoint
267+
// holding two single-tx blocks.
268+
await aztecNodeAdmin.setConfig({
269+
enforceTimeTable: true,
270+
blockDurationMs: 2000,
271+
minTxsPerBlock: 1,
272+
maxTxsPerBlock: 1,
273+
minBlocksForCheckpoint: 2,
274+
maxBlocksPerCheckpoint: 2,
275+
});
276+
await waitForSequencerIdle(t.context.sequencer!.getSequencer());
277+
278+
// Send the 2 txs. minBlocksForCheckpoint=2 keeps the sequencer from publishing until both have
279+
// been packed (one per block), so they always end up in the same checkpoint.
280+
const [{ receipt: receipt0 }, { receipt: receipt1 }] = await Promise.all([
281+
contract.methods
282+
.create_l2_to_l1_message_arbitrary_recipient_private(contents[0], recipient)
283+
.send({ from: user1Address }),
284+
contract.methods
285+
.create_l2_to_l1_message_arbitrary_recipient_private(contents[1], recipient)
286+
.send({ from: user1Address }),
287+
]);
288+
289+
// The 2 txs must land in different blocks...
290+
expect(receipt0.blockNumber).not.toEqual(receipt1.blockNumber);
291+
292+
// ...that belong to the same checkpoint, at consecutive positions within it.
293+
const block0 = (await aztecNode.getBlock(receipt0.blockNumber!, { includeTransactions: true }))!;
294+
const block1 = (await aztecNode.getBlock(receipt1.blockNumber!, { includeTransactions: true }))!;
295+
expect(block0.checkpointNumber).toEqual(block1.checkpointNumber);
296+
expect([block0.indexWithinCheckpoint, block1.indexWithinCheckpoint].sort((a, b) => a - b)).toEqual([0, 1]);
297+
298+
// Each block carries exactly its own message.
299+
expect(block0.body.txEffects.flatMap(txEffect => txEffect.l2ToL1Msgs)).toStrictEqual([
300+
computeMessageLeaf(messages[0]),
301+
]);
302+
expect(block1.body.txEffects.flatMap(txEffect => txEffect.l2ToL1Msgs)).toStrictEqual([
303+
computeMessageLeaf(messages[1]),
304+
]);
305+
306+
// Advance the epoch until proven, since the messages are inserted to the outbox when the epoch is proven.
307+
await t.advanceToEpochProven(receipt1);
308+
309+
// Consume both messages. The membership witnesses now span the checkpoint's block subtree, not just
310+
// a single block.
311+
await expectConsumeMessageToSucceed(messages[0], receipt0.txHash);
312+
await expectConsumeMessageToSucceed(messages[1], receipt1.txHash);
313+
});
254314

255315
function makeL2ToL1Message(recipient: EthAddress, content: Fr = Fr.ZERO): ViemL2ToL1Msg {
256316
return {

yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -661,9 +661,12 @@ describe('e2e_epochs/epochs_invalidate_block', () => {
661661
// P1 must have landed with insufficient attestations (the trigger for the archiver skip).
662662
await assertCheckpointInsufficientAttestations(p1Checkpoint);
663663

664-
// Restore P2 to a healthy config so a later proposer (or P2 in a future slot) can resume
665-
// the chain by invalidating P1 and posting fresh checkpoints.
666-
await p2Node.setConfig({ skipWaitForValidParentCheckpointOnL1: false, skipInvalidateBlockAsProposer: false });
664+
// Restore the bad proposers to a healthy config so later slots can resume the chain by
665+
// invalidating P1 and posting fresh checkpoints.
666+
await Promise.all([
667+
p1Node.setConfig({ skipCollectingAttestations: false, skipInvalidateBlockAsProposer: false }),
668+
p2Node.setConfig({ skipWaitForValidParentCheckpointOnL1: false, skipInvalidateBlockAsProposer: false }),
669+
]);
667670

668671
// The archiver should no longer stall: wait for the chain to advance past P2 within a
669672
// handful of slots. Note we wait on local checkpoint progress here (i.e. for the chain to

yarn-project/sequencer-client/src/client/sequencer-client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,13 @@ export class SequencerClient {
145145

146146
const { maxL2BlockGas, maxDABlockGas, maxTxsPerBlock } = capPerBlockLimits(config, rollupManaLimit, log);
147147

148-
const l1Constants = { l1GenesisTime, slotDuration: Number(slotDuration), ethereumSlotDuration, rollupManaLimit };
148+
const l1Constants = {
149+
l1GenesisTime,
150+
slotDuration: Number(slotDuration),
151+
ethereumSlotDuration,
152+
rollupManaLimit,
153+
epochDuration: config.aztecEpochDuration,
154+
};
149155

150156
const sequencer = new Sequencer(
151157
publisherFactory,

yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('CheckpointVoter HA Integration', () => {
6262
l1GenesisTime: 1n,
6363
slotDuration: 24,
6464
ethereumSlotDuration: DefaultL1ContractsConfig.ethereumSlotDuration,
65+
epochDuration: DefaultL1ContractsConfig.aztecEpochDuration,
6566
rollupManaLimit: Number.MAX_SAFE_INTEGER,
6667
};
6768

yarn-project/sequencer-client/src/sequencer/sequencer.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,14 @@ describe('sequencer', () => {
8585
let globalVariables: GlobalVariables;
8686
let l1Constants: Pick<
8787
L1RollupConstants,
88-
'l1GenesisTime' | 'slotDuration' | 'ethereumSlotDuration' | 'rollupManaLimit'
88+
'l1GenesisTime' | 'slotDuration' | 'ethereumSlotDuration' | 'rollupManaLimit' | 'epochDuration'
8989
>;
9090

9191
let sequencer: TestSequencer;
9292

9393
const slotDuration = 8;
9494
const ethereumSlotDuration = 4;
95+
const epochDuration = 16;
9596

9697
const chainId = new Fr(12345);
9798
const version = Fr.ZERO;
@@ -188,7 +189,13 @@ describe('sequencer', () => {
188189
);
189190

190191
const l1GenesisTime = BigInt(Math.floor(Date.now() / 1000));
191-
l1Constants = { l1GenesisTime, slotDuration, ethereumSlotDuration, rollupManaLimit: Number.MAX_SAFE_INTEGER };
192+
l1Constants = {
193+
l1GenesisTime,
194+
slotDuration,
195+
ethereumSlotDuration,
196+
epochDuration,
197+
rollupManaLimit: Number.MAX_SAFE_INTEGER,
198+
};
192199

193200
epochCache = mockDeep<EpochCache>();
194201
epochCache.isEscapeHatchOpen.mockResolvedValue(false);
@@ -1062,6 +1069,34 @@ describe('sequencer', () => {
10621069
sequencer.skipExecute = false;
10631070
});
10641071

1072+
it('derives the pipelined target slot from the same next-L1-slot snapshot', async () => {
1073+
await setupSingleTxBlock();
1074+
1075+
epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({
1076+
epoch: EpochNumber(1),
1077+
slot: SlotNumber(6),
1078+
ts: 1780066804n,
1079+
nowSeconds: 1780066811n,
1080+
});
1081+
epochCache.getTargetEpochAndSlotInNextL1Slot.mockReturnValue({
1082+
epoch: EpochNumber(1),
1083+
slot: SlotNumber(8),
1084+
ts: 1780066816n,
1085+
nowSeconds: 1780066812n,
1086+
});
1087+
publisher.canProposeAt.mockResolvedValue({
1088+
slot: SlotNumber(7),
1089+
checkpointNumber: CheckpointNumber.fromBlockNumber(newBlockNumber),
1090+
timeOfNextL1Slot: 1780066816n,
1091+
});
1092+
1093+
await sequencer.work();
1094+
1095+
expect(epochCache.getTargetEpochAndSlotInNextL1Slot).not.toHaveBeenCalled();
1096+
expect(epochCache.getProposerAttesterAddressInSlot).toHaveBeenCalledWith(SlotNumber(7));
1097+
expect(p2p.prepareForSlot).toHaveBeenCalledWith(SlotNumber(7));
1098+
});
1099+
10651100
it('skips L1 check when proposed checkpoint exists', async () => {
10661101
await setupSingleTxBlock();
10671102

@@ -1467,8 +1502,7 @@ class TestSequencer extends Sequencer {
14671502
this.setState(SequencerState.IDLE, undefined, { force: true });
14681503
if (this.skipExecute) {
14691504
this.setState(SequencerState.SYNCHRONIZING, undefined);
1470-
const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
1471-
const { slot: targetSlot, epoch: targetEpoch } = this.epochCache.getTargetEpochAndSlotInNextL1Slot();
1505+
const { slot, targetSlot, epoch, targetEpoch, ts, nowSeconds } = this.getSlotContextInNextL1Slot();
14721506
await this.prepareCheckpointProposal(slot, targetSlot, epoch, targetEpoch, ts, nowSeconds);
14731507
return;
14741508
}

yarn-project/sequencer-client/src/sequencer/sequencer.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getKzg } from '@aztec/blob-lib';
2-
import type { EpochCache } from '@aztec/epoch-cache';
2+
import { type EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
33
import { NoCommitteeError, type RollupContract } from '@aztec/ethereum/contracts';
44
import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
55
import { merge, omit, pick } from '@aztec/foundation/collection';
@@ -20,7 +20,7 @@ import type {
2020
} from '@aztec/stdlib/block';
2121
import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
2222
import type { ChainConfig } from '@aztec/stdlib/config';
23-
import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
23+
import { getEpochAtSlot, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
2424
import {
2525
type ResolvedSequencerConfig,
2626
type SequencerConfig,
@@ -52,6 +52,16 @@ import { SequencerState } from './utils.js';
5252

5353
export { SequencerState };
5454

55+
/** Slot snapshot used to prepare a checkpoint proposal. */
56+
type SequencerSlotContext = {
57+
slot: SlotNumber;
58+
targetSlot: SlotNumber;
59+
epoch: EpochNumber;
60+
targetEpoch: EpochNumber;
61+
ts: bigint;
62+
nowSeconds: bigint;
63+
};
64+
5565
/**
5666
* Sequencer client
5767
* - Checks whether it is elected as proposer for the next slot
@@ -220,8 +230,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
220230
@trackSpan('Sequencer.work')
221231
protected async work() {
222232
this.setState(SequencerState.SYNCHRONIZING, undefined);
223-
const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
224-
const { slot: targetSlot, epoch: targetEpoch } = this.epochCache.getTargetEpochAndSlotInNextL1Slot();
233+
const { slot, targetSlot, epoch, targetEpoch, ts, nowSeconds } = this.getSlotContextInNextL1Slot();
225234

226235
// Check if we are synced and it's our slot, grab a publisher, check previous block invalidation, etc
227236
const checkpointProposalJob = await this.prepareCheckpointProposal(
@@ -259,6 +268,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
259268
return checkpoint;
260269
}
261270

271+
/** Returns slot and target slot from a single clock snapshot. */
272+
protected getSlotContextInNextL1Slot(): SequencerSlotContext {
273+
const { slot, ts, nowSeconds, epoch } = this.epochCache.getEpochAndSlotInNextL1Slot();
274+
const targetSlot = SlotNumber(
275+
slot + (this.epochCache.isProposerPipeliningEnabled() ? PROPOSER_PIPELINING_SLOT_OFFSET : 0),
276+
);
277+
return { slot, targetSlot, epoch, targetEpoch: getEpochAtSlot(targetSlot, this.l1Constants), ts, nowSeconds };
278+
}
279+
262280
/**
263281
* Prepares the checkpoint proposal by performing all necessary checks and setup.
264282
* This is the initial step in the main loop.

yarn-project/sequencer-client/src/sequencer/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
22

33
export type SequencerRollupConstants = Pick<
44
L1RollupConstants,
5-
'ethereumSlotDuration' | 'l1GenesisTime' | 'slotDuration' | 'rollupManaLimit'
5+
'ethereumSlotDuration' | 'l1GenesisTime' | 'slotDuration' | 'rollupManaLimit' | 'epochDuration'
66
>;

yarn-project/stdlib/src/proofs/chonk_proof.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,22 @@ describe('ChonkProof', () => {
1010
expect(() => new ChonkProof(fields)).toThrow(`Invalid ChonkProof length: ${CHONK_PROOF_LENGTH + 1}`);
1111
});
1212

13+
it('empty proof holds an empty fields array', () => {
14+
const proof = ChonkProof.empty();
15+
expect(proof.fields).toEqual([]);
16+
});
17+
1318
it('isEmpty should return true for empty proof', () => {
1419
const proof = ChonkProof.empty();
1520
expect(proof.isEmpty()).toBe(true);
1621
});
1722

23+
it('serializes empty proof as a single zero length', () => {
24+
const buffer = ChonkProof.empty().toBuffer();
25+
expect(buffer).toEqual(numToUInt32BE(0));
26+
expect(buffer.length).toBe(4);
27+
});
28+
1829
it('should serialize and deserialize empty proof', () => {
1930
const original = ChonkProof.empty();
2031
const buffer = original.toBuffer();
@@ -101,6 +112,15 @@ describe('ChonkProofWithPublicInputs', () => {
101112
expect(proof.isEmpty()).toBe(true);
102113
});
103114

115+
it('empty proof round-trips with an empty fields array', () => {
116+
const original = ChonkProofWithPublicInputs.empty();
117+
expect(original.fieldsWithPublicInputs).toEqual([]);
118+
119+
const deserialized = ChonkProofWithPublicInputs.fromBuffer(original.toBuffer());
120+
expect(deserialized.fieldsWithPublicInputs).toEqual([]);
121+
expect(deserialized.isEmpty()).toBe(true);
122+
});
123+
104124
it('should serialize and deserialize proof with public inputs', () => {
105125
const baseProof = ChonkProof.random();
106126
const publicInputs = Array.from({ length: 5 }, () => Fr.random());

0 commit comments

Comments
 (0)