Skip to content

Commit 06bf30a

Browse files
authored
feat: Skip blob downloading if empty (#17746)
Implements The Greek Defense for Ignition during Fusaka. Fixes A-168 Note that this cannot be forward ported to `next` since empty blocks are no longer mapped to empty blobs, they now include an "end of block" marker: https://github.com/AztecProtocol/aztec-packages/blob/ad9938b8083e0f7fa70baa4a4e950ed582d8aa87/yarn-project/stdlib/src/block/body.ts#L15-L19
1 parent 97f3b1a commit 06bf30a

7 files changed

Lines changed: 194 additions & 26 deletions

File tree

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

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Blob } from '@aztec/blob-lib';
1+
import { Blob, EMPTY_BLOB_VERSIONED_HASH } 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';
@@ -16,6 +16,7 @@ import { bufferToHex, withoutHexPrefix } from '@aztec/foundation/string';
1616
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
1717
import { type InboxAbi, RollupAbi } from '@aztec/l1-artifacts';
1818
import {
19+
Body,
1920
CommitteeAttestation,
2021
CommitteeAttestationsAndSigners,
2122
L2Block,
@@ -926,6 +927,71 @@ describe('Archiver', () => {
926927
await retryUntil(async () => (await archiver.getBlockNumber()) === 3, 'resync', 10, 0.1);
927928
});
928929

930+
it('handles empty blob hash without downloading blob', async () => {
931+
let latestBlockNum = await archiver.getBlockNumber();
932+
expect(latestBlockNum).toEqual(0);
933+
934+
// Create a block with an empty body
935+
const emptyBlock = blocks[0];
936+
emptyBlock.body = Body.empty();
937+
938+
const emptyBlobHash = bufferToHex(EMPTY_BLOB_VERSIONED_HASH);
939+
const rollupTx = await makeRollupTx(emptyBlock);
940+
941+
mockL1BlockNumbers(100n);
942+
943+
mockRollup.read.status.mockResolvedValue([0n, GENESIS_ROOT, 1n, emptyBlock.archive.root.toString(), GENESIS_ROOT]);
944+
945+
makeL2BlockProposedEvent(70n, 1n, emptyBlock.archive.root.toString(), [emptyBlobHash]);
946+
947+
// Mock getBlobSidecar to return empty array (simulating blob not downloaded)
948+
blobSinkClient.getBlobSidecar.mockResolvedValueOnce([]);
949+
950+
publicClient.getTransaction.mockResolvedValueOnce(rollupTx);
951+
952+
await archiver.start(false);
953+
954+
// Wait until block 1 is processed
955+
await waitUntilArchiverBlock(1);
956+
957+
latestBlockNum = await archiver.getBlockNumber();
958+
expect(latestBlockNum).toEqual(1);
959+
960+
// Verify the block was synced successfully
961+
const syncedBlock = await archiver.getBlock(1);
962+
expect(syncedBlock).toBeDefined();
963+
expect(syncedBlock!.body.txEffects.length).toEqual(0);
964+
}, 10_000);
965+
966+
it('throws error when blob hashes and bodies mismatch (non-empty case)', async () => {
967+
let latestBlockNum = await archiver.getBlockNumber();
968+
expect(latestBlockNum).toEqual(0);
969+
970+
const block = blocks[0];
971+
const blobHashes = await makeVersionedBlobHashes(block);
972+
const rollupTx = await makeRollupTx(block);
973+
974+
mockL1BlockNumbers(100n);
975+
976+
mockRollup.read.status.mockResolvedValue([0n, GENESIS_ROOT, 1n, block.archive.root.toString(), GENESIS_ROOT]);
977+
978+
makeL2BlockProposedEvent(70n, 1n, block.archive.root.toString(), blobHashes);
979+
980+
// Mock getBlobSidecar to return empty array (missing blobs)
981+
blobSinkClient.getBlobSidecar.mockResolvedValueOnce([]);
982+
983+
publicClient.getTransaction.mockResolvedValueOnce(rollupTx);
984+
985+
await archiver.start(false);
986+
987+
// Give it some time to attempt processing
988+
await sleep(1000);
989+
990+
// Should still be at block 0 since the blob fetch failed
991+
latestBlockNum = await archiver.getBlockNumber();
992+
expect(latestBlockNum).toEqual(0);
993+
}, 10_000);
994+
929995
// TODO(palla/reorg): Add a unit test for the archiver handleEpochPrune
930996
xit('handles an upcoming L2 prune', () => {});
931997

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

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Blob, BlobDeserializationError } from '@aztec/blob-lib';
1+
import { Blob, BlobDeserializationError, EMPTY_BLOB_VERSIONED_HASH } from '@aztec/blob-lib';
22
import type { BlobSinkClientInterface } from '@aztec/blob-sink/client';
33
import type {
44
EpochProofPublicInputArgs,
@@ -14,6 +14,7 @@ import type { EthAddress } from '@aztec/foundation/eth-address';
1414
import type { ViemSignature } from '@aztec/foundation/eth-signature';
1515
import { Fr } from '@aztec/foundation/fields';
1616
import { type Logger, createLogger } from '@aztec/foundation/log';
17+
import { bufferToHex } from '@aztec/foundation/string';
1718
import { type InboxAbi, RollupAbi } from '@aztec/l1-artifacts';
1819
import { Body, CommitteeAttestation, L2Block, PublishedL2Block } from '@aztec/stdlib/block';
1920
import { Proof } from '@aztec/stdlib/proofs';
@@ -340,38 +341,60 @@ async function getBlockFromRollupTx(
340341
// TODO(md): why is the proposed block header different to the actual block header?
341342
// This is likely going to be a footgun
342343
const header = ProposedBlockHeader.fromViem(decodedArgs.header);
344+
const body = await getBlockBodyFromBlobs(blobSinkClient, blockHash!, blobHashes, l2BlockNumber, logger);
345+
346+
const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive)));
347+
348+
const stateReference = StateReference.fromViem(decodedArgs.stateReference);
349+
350+
return {
351+
l2BlockNumber,
352+
archiveRoot,
353+
stateReference,
354+
header,
355+
body,
356+
attestations,
357+
};
358+
}
359+
360+
async function getBlockBodyFromBlobs(
361+
blobSinkClient: BlobSinkClientInterface,
362+
blockHash: string,
363+
blobHashes: Buffer<ArrayBufferLike>[],
364+
l2BlockNumber: number,
365+
logger: Logger,
366+
) {
343367
const blobBodies = await blobSinkClient.getBlobSidecar(blockHash, blobHashes);
344-
if (blobBodies.length === 0) {
345-
throw new NoBlobBodiesFoundError(l2BlockNumber);
368+
logger.trace(`Fetched ${blobBodies.length} blob bodies for L2 block ${l2BlockNumber}`, {
369+
blobHashes: blobHashes.map(bufferToHex),
370+
l2BlockNumber,
371+
});
372+
373+
if (blobBodies.length !== blobHashes.length) {
374+
// If there is exactly one blob hash and it is the empty blob hash, we are fine with not downloading it
375+
// and just defaulting to an empty block body.
376+
if (blobHashes.length === 1 && blobBodies.length === 0 && blobHashes[0].equals(EMPTY_BLOB_VERSIONED_HASH)) {
377+
logger.verbose(`Ignoring error fetching blob body for block ${l2BlockNumber} as it is empty`);
378+
return Body.empty();
379+
} else {
380+
throw new NoBlobBodiesFoundError(l2BlockNumber, blobBodies.length, blobHashes.length);
381+
}
346382
}
347383

348384
let blockFields: Fr[];
349385
try {
350386
blockFields = Blob.toEncodedFields(blobBodies.map(b => b.blob));
351387
} catch (err: any) {
352388
if (err instanceof BlobDeserializationError) {
353-
logger.fatal(err.message);
389+
logger.error(err.message);
354390
} else {
355-
logger.fatal('Unable to sync: failed to decode fetched blob, this blob was likely not created by us');
391+
logger.error('Unable to sync: failed to decode fetched blob, this blob was likely not created by us');
356392
}
357393
throw err;
358394
}
359395

360396
// The blob source gives us blockFields, and we must construct the body from them:
361-
const body = Body.fromBlobFields(blockFields);
362-
363-
const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive)));
364-
365-
const stateReference = StateReference.fromViem(decodedArgs.stateReference);
366-
367-
return {
368-
l2BlockNumber,
369-
archiveRoot,
370-
stateReference,
371-
header,
372-
body,
373-
attestations,
374-
};
397+
return Body.fromBlobFields(blockFields);
375398
}
376399

377400
/** Given an L1 to L2 message, retrieves its corresponding event from the Inbox within a specific block range. */

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export class NoBlobBodiesFoundError extends Error {
2-
constructor(l2BlockNum: number) {
3-
super(`No blob bodies found for block ${l2BlockNum}`);
2+
constructor(l2BlockNum: number, found: number, expected: number) {
3+
super(`No blob bodies found for block ${l2BlockNum} (expected ${expected} but found ${found})`);
44
}
55
}
66

yarn-project/blob-lib/src/blob.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { poseidon2Hash } from '@aztec/foundation/crypto';
22
import { Fr } from '@aztec/foundation/fields';
3+
import { bufferToHex } from '@aztec/foundation/string';
34

45
import cKzg from 'c-kzg';
56
import type { Blob as BlobBuffer, Bytes48, KZGProof } from 'c-kzg';
67

7-
import { Blob } from './index.js';
8+
import { Blob, EMPTY_BLOB_VERSIONED_HASH } from './index.js';
89
import { makeEncodedBlob } from './testing.js';
910

1011
// Importing directly from 'c-kzg' does not work:
@@ -141,4 +142,10 @@ describe('blob', () => {
141142
const deserialisedBlob = await Blob.fromJson(blobJson);
142143
expect(blob.fieldsHash.equals(deserialisedBlob.fieldsHash)).toBe(true);
143144
});
145+
146+
it('computes correct eth versioned blob hash for an empty blob', async () => {
147+
const blob = await Blob.fromFields([]);
148+
const versionedHash = blob.getEthVersionedBlobHash();
149+
expect(bufferToHex(versionedHash)).toBe(bufferToHex(EMPTY_BLOB_VERSIONED_HASH));
150+
});
144151
});

yarn-project/blob-lib/src/blob.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ const { BYTES_PER_BLOB, FIELD_ELEMENTS_PER_BLOB, blobToKzgCommitment, computeKzg
1515
// The prefix to the EVM blobHash, defined here: https://eips.ethereum.org/EIPS/eip-4844#specification
1616
export const VERSIONED_HASH_VERSION_KZG = 0x01;
1717

18+
/** Versioned blob hash for an empty blob */
19+
export const EMPTY_BLOB_VERSIONED_HASH = Buffer.from(
20+
`010657f37554c781402a22917dee2f75def7ab966d7b770905398eba3c444014`,
21+
'hex',
22+
);
23+
1824
/**
1925
* A class to create, manage, and prove EVM blobs.
2026
*/

yarn-project/blob-sink/src/server/server.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export class BlobSinkServer {
3333
private metrics: BlobSinkMetrics;
3434
private log: Logger = createLogger('blob-sink:server');
3535

36+
private disableStorage = false;
37+
3638
constructor(
3739
config: BlobSinkConfig = {},
3840
private store?: AztecAsyncKVStore,
@@ -69,6 +71,12 @@ export class BlobSinkServer {
6971
private async handleGetBlobs(req: Request, res: Response) {
7072
const { blobHashes: blobHashesQuery } = req.query;
7173

74+
if (this.disableStorage) {
75+
this.log.warn(`Blob storage is disabled, cannot retrieve blobs`);
76+
res.json({ version: 'deneb', data: [] });
77+
return;
78+
}
79+
7280
try {
7381
// Parse blob hashes from comma-separated hex strings
7482
if (!blobHashesQuery || typeof blobHashesQuery !== 'string') {
@@ -124,11 +132,15 @@ export class BlobSinkServer {
124132
}
125133

126134
try {
127-
await this.blobStore.addBlobs(blobObjects);
128-
this.metrics.recordBlobReceipt(blobObjects);
129135
const blobHashes = blobObjects.map(blob => bufferToHex(blob.blob.getEthVersionedBlobHash()));
130-
this.log.info(`Blobs stored successfully`, { blobHashes });
136+
if (this.disableStorage) {
137+
this.log.warn(`Blob storage is disabled, not storing blobs`);
138+
} else {
139+
await this.blobStore.addBlobs(blobObjects);
140+
this.log.info(`Blobs stored successfully`, { blobHashes });
141+
}
131142
res.json({ blobHashes });
143+
this.metrics.recordBlobReceipt(blobObjects);
132144
this.metrics.incStoreBlob(true);
133145
} catch (error: any) {
134146
this.log.error(`Error storing blob sidecar`, error);
@@ -156,6 +168,10 @@ export class BlobSinkServer {
156168
);
157169
}
158170

171+
public setDisableBlobStorage(value: boolean) {
172+
this.disableStorage = value;
173+
}
174+
159175
public start(): Promise<void> {
160176
return new Promise((resolve, reject) => {
161177
this.server = this.app.listen(this.port, () => {

yarn-project/end-to-end/src/e2e_block_building.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,56 @@ describe('e2e_block_building', () => {
679679
});
680680
});
681681

682+
describe('empty blocks', () => {
683+
let wallet: Wallet;
684+
685+
afterEach(async () => {
686+
if (teardown) {
687+
await teardown();
688+
}
689+
});
690+
691+
it('syncs blocks with no access to blobs if they are empty', async () => {
692+
const context = await setup(1, {
693+
minTxsPerBlock: 0,
694+
skipProtocolContracts: true,
695+
numberOfInitialFundedAccounts: 1,
696+
ethereumSlotDuration: 4,
697+
aztecSlotDuration: 8,
698+
aztecProofSubmissionEpochs: 32,
699+
automineL1Setup: true,
700+
});
701+
702+
({
703+
teardown,
704+
logger,
705+
aztecNode,
706+
wallet,
707+
accounts: [ownerAddress],
708+
} = context);
709+
710+
const testContract = await TestContract.deploy(wallet).send({ from: ownerAddress }).deployed();
711+
const deploymentBlock = await aztecNode.getBlockNumber();
712+
logger.warn(`Test contract deployed at ${testContract.address} at block ${deploymentBlock}`);
713+
714+
// Mock the blob sink to prevent blob storage
715+
logger.warn(`Disabling blob storage`);
716+
context.blobSink!.setDisableBlobStorage(true);
717+
718+
// Produce an empty block (no txs) - should sync fine without blobs
719+
logger.warn('Producing empty block without blob publishing');
720+
await retryUntil(async () => (await aztecNode.getBlockNumber()) > deploymentBlock + 1, 'wait-empty-block', 24, 1);
721+
const emptyBlockNumber = await aztecNode.getBlockNumber();
722+
logger.warn(`Empty block ${emptyBlockNumber} synced`);
723+
724+
// Now produce a block with txs (blobs still blocked)
725+
logger.warn('Producing block with txs but no blob publishing');
726+
await expect(() =>
727+
testContract.methods.emit_nullifier(Fr.random()).send({ from: ownerAddress }).wait({ timeout: 12 }),
728+
).rejects.toThrow(/time/i);
729+
});
730+
});
731+
682732
const interceptTxProcessorSimulate = (
683733
node: AztecNodeService,
684734
stub: (tx: Tx, originalSimulate: (tx: Tx) => Promise<PublicTxResult>) => Promise<PublicTxResult>,

0 commit comments

Comments
 (0)