Skip to content

Commit e52701b

Browse files
authored
feat: Archiver does not sync blocks with invalid attestations (#15896)
Archiver now checks committee attestations and refuses to sync a block if it does not pass validation. Note that this addresses scenarios where the proposer is malicious, but does not handle cases where the entire committee is and produces signatures for a block with an unattested parent. That'll be left for a future PR. Builds on #15813
1 parent f7f5568 commit e52701b

File tree

23 files changed

+428
-113
lines changed

23 files changed

+428
-113
lines changed

yarn-project/archiver/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"@aztec/blob-lib": "workspace:^",
7070
"@aztec/blob-sink": "workspace:^",
7171
"@aztec/constants": "workspace:^",
72+
"@aztec/epoch-cache": "workspace:^",
7273
"@aztec/ethereum": "workspace:^",
7374
"@aztec/foundation": "workspace:^",
7475
"@aztec/kv-store": "workspace:^",

yarn-project/archiver/src/archiver/archiver.test.ts

Lines changed: 112 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { Blob } from '@aztec/blob-lib';
22
import type { BlobSinkClientInterface } from '@aztec/blob-sink/client';
33
import { BlobWithIndex } from '@aztec/blob-sink/types';
44
import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants';
5+
import type { EpochCache, EpochCommitteeInfo } from '@aztec/epoch-cache';
56
import { DefaultL1ContractsConfig, InboxContract, RollupContract, type ViemPublicClient } from '@aztec/ethereum';
67
import { Buffer16, Buffer32 } from '@aztec/foundation/buffer';
78
import { times } from '@aztec/foundation/collection';
9+
import { Secp256k1Signer } from '@aztec/foundation/crypto';
810
import { EthAddress } from '@aztec/foundation/eth-address';
911
import { Fr } from '@aztec/foundation/fields';
1012
import { type Logger, createLogger } from '@aztec/foundation/log';
@@ -13,10 +15,11 @@ import { sleep } from '@aztec/foundation/sleep';
1315
import { bufferToHex, withoutHexPrefix } from '@aztec/foundation/string';
1416
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
1517
import { type InboxAbi, RollupAbi } from '@aztec/l1-artifacts';
16-
import { L2Block } from '@aztec/stdlib/block';
18+
import { CommitteeAttestation, L2Block } from '@aztec/stdlib/block';
1719
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
1820
import { PrivateLog } from '@aztec/stdlib/logs';
1921
import { InboxLeaf } from '@aztec/stdlib/messaging';
22+
import { makeBlockAttestationFromBlock } from '@aztec/stdlib/testing';
2023
import { getTelemetryClient } from '@aztec/telemetry-client';
2124

2225
import { jest } from '@jest/globals';
@@ -30,6 +33,8 @@ import { KVArchiverDataStore } from './kv_archiver_store/kv_archiver_store.js';
3033
import { updateRollingHash } from './structs/inbox_message.js';
3134

3235
interface MockRollupContractRead {
36+
/** Returns the target committee size */
37+
getTargetCommitteeSize: () => Promise<bigint>;
3338
/** Returns the rollup version. */
3439
getVersion: () => Promise<bigint>;
3540
/** Given an L2 block number, returns the archive. */
@@ -81,9 +86,19 @@ describe('Archiver', () => {
8186
publicClient.getBlockNumber.mockResolvedValue(nums.at(-1)!);
8287
};
8388

89+
const makeBlock = async (blockNumber: number) => {
90+
const block = await L2Block.random(blockNumber, txsPerBlock, blockNumber + 1, 2);
91+
block.header.globalVariables.timestamp = BigInt(now + Number(ETHEREUM_SLOT_DURATION) * (blockNumber + 1));
92+
block.body.txEffects.forEach((txEffect, i) => {
93+
txEffect.privateLogs = times(getNumPrivateLogsForTx(block.number, i), () => PrivateLog.random());
94+
});
95+
return block;
96+
};
97+
8498
let publicClient: MockProxy<ViemPublicClient>;
8599
let instrumentation: MockProxy<ArchiverInstrumentation>;
86100
let blobSinkClient: MockProxy<BlobSinkClientInterface>;
101+
let epochCache: MockProxy<EpochCache>;
87102
let archiverStore: ArchiverDataStore;
88103
let l1Constants: L1RollupConstants & { l1StartBlockHash: Buffer32 };
89104
let now: number;
@@ -132,6 +147,8 @@ describe('Archiver', () => {
132147
}) as any);
133148

134149
blobSinkClient = mock<BlobSinkClientInterface>();
150+
epochCache = mock<EpochCache>();
151+
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee: [] as EthAddress[] } as EpochCommitteeInfo);
135152

136153
const tracer = getTelemetryClient().getTracer('');
137154
instrumentation = mock<ArchiverInstrumentation>({ isEnabled: () => true, tracer });
@@ -152,17 +169,12 @@ describe('Archiver', () => {
152169
archiverStore,
153170
{ pollingIntervalMs: 1000, batchSize: 1000 },
154171
blobSinkClient,
172+
epochCache,
155173
instrumentation,
156174
l1Constants,
157175
);
158176

159-
blocks = await Promise.all(blockNumbers.map(x => L2Block.random(x, txsPerBlock, x + 1, 2)));
160-
blocks.forEach((block, i) => {
161-
block.header.globalVariables.timestamp = BigInt(now + Number(ETHEREUM_SLOT_DURATION) * (i + 1));
162-
block.body.txEffects.forEach((txEffect, i) => {
163-
txEffect.privateLogs = times(getNumPrivateLogsForTx(block.number, i), () => PrivateLog.random());
164-
});
165-
});
177+
blocks = await Promise.all(blockNumbers.map(makeBlock));
166178

167179
// TODO(palla/archiver) Instead of guessing the archiver requests with mockResolvedValueOnce,
168180
// we should use a mock implementation that returns the expected value based on the input.
@@ -171,7 +183,7 @@ describe('Archiver', () => {
171183
// blobsFromBlocks = await Promise.all(blocks.map(b => makeBlobsFromBlock(b)));
172184
// blobsFromBlocks.forEach(blobs => blobSinkClient.getBlobSidecar.mockResolvedValueOnce(blobs));
173185

174-
// rollupTxs = await Promise.all(blocks.map(makeRollupTx));
186+
// rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
175187
// publicClient.getTransaction.mockImplementation((args: { hash?: `0x${string}` }) => {
176188
// const index = parseInt(withoutHexPrefix(args.hash!));
177189
// if (index > blocks.length) {
@@ -252,7 +264,7 @@ describe('Archiver', () => {
252264
let latestBlockNum = await archiver.getBlockNumber();
253265
expect(latestBlockNum).toEqual(0);
254266

255-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
267+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
256268
const blobHashes = await Promise.all(blocks.map(makeVersionedBlobHashes));
257269

258270
mockL1BlockNumbers(2500n, 2510n, 2520n);
@@ -334,7 +346,7 @@ describe('Archiver', () => {
334346

335347
const numL2BlocksInTest = 2;
336348

337-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
349+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
338350
const blobHashes = await Promise.all(blocks.map(makeVersionedBlobHashes));
339351

340352
// Here we set the current L1 block number to 102. L1 to L2 messages after this should not be read.
@@ -368,6 +380,81 @@ describe('Archiver', () => {
368380
});
369381
}, 10_000);
370382

383+
it('ignores block 2 because it had invalid attestations', async () => {
384+
let latestBlockNum = await archiver.getBlockNumber();
385+
expect(latestBlockNum).toEqual(0);
386+
387+
// Setup a committee of 3 signers
388+
mockRollupRead.getTargetCommitteeSize.mockResolvedValue(3n);
389+
const signers = times(3, Secp256k1Signer.random);
390+
const committee = signers.map(signer => signer.address);
391+
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee } as EpochCommitteeInfo);
392+
393+
// Add the attestations from the signers to all 3 blocks
394+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b, signers)));
395+
const blobHashes = await Promise.all(blocks.map(makeVersionedBlobHashes));
396+
const blobsFromBlocks = await Promise.all(blocks.map(b => makeBlobsFromBlock(b)));
397+
398+
// And define a bad block 2 with attestations from random signers
399+
const badBlock2 = await makeBlock(2);
400+
badBlock2.archive.root = new Fr(0x1002);
401+
const badBlock2RollupTx = await makeRollupTx(badBlock2, times(3, Secp256k1Signer.random));
402+
const badBlock2BlobHashes = await makeVersionedBlobHashes(badBlock2);
403+
const badBlock2Blobs = await makeBlobsFromBlock(badBlock2);
404+
405+
// Return the archive root for the bad block 2 when queried
406+
mockRollupRead.archiveAt.mockImplementation((args: readonly [bigint]) =>
407+
Promise.resolve((args[0] === 2n ? badBlock2 : blocks[Number(args[0] - 1n)]).archive.root.toString()),
408+
);
409+
410+
logger.warn(`Created 3 valid blocks`);
411+
blocks.forEach(block => logger.warn(`Block ${block.number} with root ${block.archive.root.toString()}`));
412+
logger.warn(`Created invalid block 2 with root ${badBlock2.archive.root.toString()}`);
413+
414+
// During the first archiver loop, we fetch block 1 and the block 2 with bad attestations
415+
publicClient.getBlockNumber.mockResolvedValue(85n);
416+
makeL2BlockProposedEvent(70n, 1n, blocks[0].archive.root.toString(), blobHashes[0]);
417+
makeL2BlockProposedEvent(80n, 2n, badBlock2.archive.root.toString(), badBlock2BlobHashes);
418+
mockRollup.read.status.mockResolvedValue([0n, GENESIS_ROOT, 2n, badBlock2.archive.root.toString(), GENESIS_ROOT]);
419+
publicClient.getTransaction.mockResolvedValueOnce(rollupTxs[0]).mockResolvedValueOnce(badBlock2RollupTx);
420+
blobSinkClient.getBlobSidecar.mockResolvedValueOnce(blobsFromBlocks[0]).mockResolvedValueOnce(badBlock2Blobs);
421+
422+
// Start archiver, the bad block 2 should not be synced
423+
await archiver.start(true);
424+
latestBlockNum = await archiver.getBlockNumber();
425+
expect(latestBlockNum).toEqual(1);
426+
427+
// Now we go for another loop, where a proper block 2 is proposed with correct attestations
428+
// IRL there would be an "Invalidated" event, but we are not currently relying on it
429+
logger.warn(`Adding new block 2 with correct attestations and a block 3`);
430+
publicClient.getBlockNumber.mockResolvedValue(100n);
431+
makeL2BlockProposedEvent(90n, 2n, blocks[1].archive.root.toString(), blobHashes[1]);
432+
makeL2BlockProposedEvent(95n, 3n, blocks[2].archive.root.toString(), blobHashes[2]);
433+
mockRollup.read.status.mockResolvedValue([
434+
0n,
435+
GENESIS_ROOT,
436+
3n,
437+
blocks[2].archive.root.toString(),
438+
blocks[0].archive.root.toString(),
439+
]);
440+
publicClient.getTransaction.mockResolvedValueOnce(rollupTxs[1]).mockResolvedValueOnce(rollupTxs[2]);
441+
blobSinkClient.getBlobSidecar.mockResolvedValueOnce(blobsFromBlocks[1]).mockResolvedValueOnce(blobsFromBlocks[2]);
442+
mockRollupRead.archiveAt.mockImplementation((args: readonly [bigint]) =>
443+
Promise.resolve(blocks[Number(args[0] - 1n)].archive.root.toString()),
444+
);
445+
446+
// Now we should move to block 3
447+
await waitUntilArchiverBlock(3);
448+
latestBlockNum = await archiver.getBlockNumber();
449+
expect(latestBlockNum).toEqual(3);
450+
451+
// And block 2 should return the proper one
452+
const [block2] = await archiver.getPublishedBlocks(2, 1);
453+
expect(block2.block.number).toEqual(2);
454+
expect(block2.block.archive.root.toString()).toEqual(blocks[1].archive.root.toString());
455+
expect(block2.attestations.length).toEqual(3);
456+
}, 10_000);
457+
371458
it('skip event search if no changes found', async () => {
372459
const loggerSpy = jest.spyOn((archiver as any).log, 'debug');
373460

@@ -376,7 +463,7 @@ describe('Archiver', () => {
376463

377464
const numL2BlocksInTest = 2;
378465

379-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
466+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
380467
const blobHashes = await Promise.all(blocks.map(makeVersionedBlobHashes));
381468

382469
mockL1BlockNumbers(50n, 100n);
@@ -414,7 +501,7 @@ describe('Archiver', () => {
414501

415502
const numL2BlocksInTest = 2;
416503

417-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
504+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
418505
const blobHashes = await Promise.all(blocks.map(makeVersionedBlobHashes));
419506

420507
let mockedBlockNum = 0n;
@@ -460,7 +547,7 @@ describe('Archiver', () => {
460547
// Lets take a look to see if we can find re-org stuff!
461548
await sleep(2000);
462549

463-
expect(loggerSpy).toHaveBeenCalledWith(`L2 prune has been detected.`);
550+
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining(`L2 prune has been detected`), expect.anything());
464551

465552
// Should also see the block number be reduced
466553
latestBlockNum = await archiver.getBlockNumber();
@@ -538,7 +625,7 @@ describe('Archiver', () => {
538625
blocks = [l2Block];
539626
const blobHashes = await makeVersionedBlobHashes(l2Block);
540627

541-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
628+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
542629
publicClient.getBlockNumber.mockResolvedValue(l1BlockForL2Block);
543630
mockRollup.read.status.mockResolvedValueOnce([0n, GENESIS_ROOT, 1n, l2Block.archive.root.toString(), GENESIS_ROOT]);
544631
makeL2BlockProposedEvent(l1BlockForL2Block, 1n, l2Block.archive.root.toString(), blobHashes);
@@ -570,7 +657,7 @@ describe('Archiver', () => {
570657
blocks = [l2Block];
571658
const blobHashes = await makeVersionedBlobHashes(l2Block);
572659

573-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
660+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
574661
publicClient.getBlockNumber.mockResolvedValue(l1BlockForL2Block);
575662
mockRollup.read.status.mockResolvedValueOnce([0n, GENESIS_ROOT, 1n, l2Block.archive.root.toString(), GENESIS_ROOT]);
576663
makeL2BlockProposedEvent(l1BlockForL2Block, 1n, l2Block.archive.root.toString(), blobHashes);
@@ -630,7 +717,7 @@ describe('Archiver', () => {
630717
blocks = [l2Block];
631718
const blobHashes = await makeVersionedBlobHashes(l2Block);
632719

633-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
720+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
634721
publicClient.getBlockNumber.mockResolvedValue(lastL1BlockForEpoch);
635722
mockRollup.read.status.mockResolvedValueOnce([0n, GENESIS_ROOT, 1n, l2Block.archive.root.toString(), GENESIS_ROOT]);
636723
makeL2BlockProposedEvent(l1BlockForL2Block, 1n, l2Block.archive.root.toString(), blobHashes);
@@ -660,7 +747,7 @@ describe('Archiver', () => {
660747
it('handles a block gap due to a spurious L2 prune', async () => {
661748
expect(await archiver.getBlockNumber()).toEqual(0);
662749

663-
const rollupTxs = await Promise.all(blocks.map(makeRollupTx));
750+
const rollupTxs = await Promise.all(blocks.map(b => makeRollupTx(b)));
664751
const blobHashes = await Promise.all(blocks.map(makeVersionedBlobHashes));
665752
const blobsFromBlocks = await Promise.all(blocks.map(b => makeBlobsFromBlock(b)));
666753

@@ -805,7 +892,11 @@ describe('Archiver', () => {
805892
* @param block - The L2Block.
806893
* @returns A fake tx with calldata that corresponds to calling process in the Rollup contract.
807894
*/
808-
async function makeRollupTx(l2Block: L2Block) {
895+
async function makeRollupTx(l2Block: L2Block, signers: Secp256k1Signer[] = []) {
896+
const attestations = signers
897+
.map(signer => makeBlockAttestationFromBlock(l2Block, signer))
898+
.map(blockAttestation => CommitteeAttestation.fromSignature(blockAttestation.signature))
899+
.map(committeeAttestation => committeeAttestation.toViem());
809900
const header = l2Block.header.toPropose().toViem();
810901
const blobInput = Blob.getPrefixedEthBlobCommitments(await Blob.getBlobsPerBlock(l2Block.body.toBlobFields()));
811902
const archive = toHex(l2Block.archive.root.toBuffer());
@@ -820,8 +911,8 @@ async function makeRollupTx(l2Block: L2Block) {
820911
stateReference,
821912
oracleInput: { feeAssetPriceModifier: 0n },
822913
},
823-
RollupContract.packAttestations([]),
824-
[],
914+
RollupContract.packAttestations(attestations),
915+
signers.map(signer => signer.address.toString()),
825916
blobInput,
826917
],
827918
});

0 commit comments

Comments
 (0)