From d023d3fa8db45bc669bfc6099e64258d01f0bd95 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:02:57 -0400 Subject: [PATCH 01/10] chore: backport #21754 (feat!: make isContractInitialized a tri-state enum) to v4-next (#21792) --- .../docs/aztec-js/how_to_create_account.md | 2 +- .../docs/aztec-js/how_to_deploy_contract.md | 2 +- .../docs/aztec-js/how_to_deploy_contract.md | 2 +- .../docs/resources/migration_notes.md | 23 ++++++- docs/examples/ts/aztecjs_connection/index.ts | 2 +- .../navbar/components/WalletHub.tsx | 6 +- yarn-project/aztec.js/src/api/wallet.ts | 1 + .../aztec.js/src/wallet/wallet.test.ts | 6 +- yarn-project/aztec.js/src/wallet/wallet.ts | 22 +++++-- yarn-project/bot/src/factory.ts | 3 +- .../end-to-end/src/e2e_block_building.test.ts | 5 +- .../e2e_deploy_contract/deploy_method.test.ts | 17 +++++- .../private_initialization.test.ts | 27 ++++++++ .../wallet-sdk/src/base-wallet/base_wallet.ts | 61 +++++++++++-------- 14 files changed, 131 insertions(+), 48 deletions(-) diff --git a/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_create_account.md b/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_create_account.md index 626a82ad921e..bc1ee3bdc337 100644 --- a/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_create_account.md +++ b/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_create_account.md @@ -103,7 +103,7 @@ Confirm the account was deployed successfully: ```typescript title="verify_account_deployment" showLineNumbers const metadata = await wallet.getContractMetadata(newAccount.address); -console.log("Account deployed:", metadata.isContractInitialized); +console.log("Account deployed:", metadata.initializationStatus); ``` > Source code: docs/examples/ts/aztecjs_connection/index.ts#L86-L89 diff --git a/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_deploy_contract.md b/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_deploy_contract.md index f3972921bd73..bfc25a8682e3 100644 --- a/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_deploy_contract.md +++ b/docs/developer_versioned_docs/version-v4.0.0-nightly.20260217/docs/aztec-js/how_to_deploy_contract.md @@ -366,7 +366,7 @@ const metadata = await wallet.getContractMetadata(contractAddress); metadata.instance; // Contract registered in your wallet? metadata.isContractClassPubliclyRegistered; // Class registered on the network? metadata.isContractPublished; // Instance registered on the network? -metadata.isContractInitialized; // Constructor has been called? +metadata.initializationStatus; // Constructor has been called? ``` For a complete overview of what these states mean and when functions become callable, see [Contract Readiness States](../aztec-nr/contract_readiness_states.md). diff --git a/docs/docs-developers/docs/aztec-js/how_to_deploy_contract.md b/docs/docs-developers/docs/aztec-js/how_to_deploy_contract.md index 3871fa7f529f..67e3d6e9e5ed 100644 --- a/docs/docs-developers/docs/aztec-js/how_to_deploy_contract.md +++ b/docs/docs-developers/docs/aztec-js/how_to_deploy_contract.md @@ -168,7 +168,7 @@ const metadata = await wallet.getContractMetadata(contractAddress); metadata.instance; // Contract registered in your wallet? metadata.isContractClassPubliclyRegistered; // Class registered on the network? metadata.isContractPublished; // Instance registered on the network? -metadata.isContractInitialized; // Constructor has been called? +metadata.initializationStatus; // Constructor has been called? ``` For a complete overview of what these states mean and when functions become callable, see [Contract Readiness States](../aztec-nr/contract_readiness_states.md). diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 2f53566279aa..0b31954fd9b9 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,28 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [aztec.js] `isContractInitialized` is now `initializationStatus` tri-state enum + +`ContractMetadata.isContractInitialized` has been renamed to `ContractMetadata.initializationStatus` and changed from `boolean | undefined` to a `ContractInitializationStatus` enum with values `INITIALIZED`, `UNINITIALIZED`, and `UNKNOWN`. + +- `INITIALIZED`: the contract has been initialized (initialization nullifier found) +- `UNINITIALIZED`: the contract instance is registered but has not been initialized +- `UNKNOWN`: the instance is not registered and no public initialization nullifier was found + +When the instance is not registered, the wallet now attempts to check the public initialization nullifier (computed from address alone) before returning `UNKNOWN`. Previously this case returned `undefined`. + +**Migration:** + +```diff ++ import { ContractInitializationStatus } from '@aztec/aztec.js/wallet'; + + const metadata = await wallet.getContractMetadata(address); +- if (metadata.isContractInitialized) { ++ if (metadata.initializationStatus === ContractInitializationStatus.INITIALIZED) { + // contract is initialized + } +``` + ### [Aztec.js] Use `NO_FROM` instead of `AztecAddress.ZERO` to bypass account contract entrypoint When sending transactions that should not be mediated by an account contract (e.g., account contract self-deployments), use the explicit `NO_FROM` sentinel instead of `AztecAddress.ZERO`. @@ -64,7 +86,6 @@ The `scope` field in `ExecuteUtilityOptions` has been renamed to `scopes` and ch ``` **Impact**: Any code that calls `wallet.executeUtility` directly must update the options object. Wallets must update to adapt to the new interface - ### [Aztec.nr] `attempt_note_discovery` now takes two separate functions instead of one The `attempt_note_discovery` function (and related discovery functions like `do_sync_state`, `process_message_ciphertext`) now takes separate `compute_note_hash` and `compute_note_nullifier` arguments instead of a single combined `compute_note_hash_and_nullifier`. The corresponding type aliases are now `ComputeNoteHash` and `ComputeNoteNullifier` (instead of `ComputeNoteHashAndNullifier`). diff --git a/docs/examples/ts/aztecjs_connection/index.ts b/docs/examples/ts/aztecjs_connection/index.ts index ff778ea72222..a9d7643f99ca 100644 --- a/docs/examples/ts/aztecjs_connection/index.ts +++ b/docs/examples/ts/aztecjs_connection/index.ts @@ -85,7 +85,7 @@ await deployMethodFeeJuice.send({ // docs:start:verify_account_deployment const metadata = await wallet.getContractMetadata(newAccount.address); -console.log("Account deployed:", metadata.isContractInitialized); +console.log("Account deployed:", metadata.initializationStatus); // docs:end:verify_account_deployment // docs:start:deploy_contract diff --git a/playground/src/components/navbar/components/WalletHub.tsx b/playground/src/components/navbar/components/WalletHub.tsx index 4d4707c3604a..b214559e132e 100644 --- a/playground/src/components/navbar/components/WalletHub.tsx +++ b/playground/src/components/navbar/components/WalletHub.tsx @@ -36,7 +36,7 @@ import { } from '@aztec/wallet-sdk/manager'; import { hashToEmoji } from '@aztec/wallet-sdk/crypto'; import { Fr } from '@aztec/foundation/curves/bn254'; -import type { Wallet } from '@aztec/aztec.js/wallet'; +import { ContractInitializationStatus, type Wallet } from '@aztec/aztec.js/wallet'; type ExtendedWalletProvider = Omit & { type: WalletProvider['type'] | 'embedded'; @@ -86,8 +86,8 @@ async function discoverTestAccounts(wallet: EmbeddedWallet) { return; } - const { isContractInitialized } = await wallet.getContractMetadata(sampleAccount.address); - if (!isContractInitialized) { + const { initializationStatus } = await wallet.getContractMetadata(sampleAccount.address); + if (initializationStatus !== ContractInitializationStatus.INITIALIZED) { return; } diff --git a/yarn-project/aztec.js/src/api/wallet.ts b/yarn-project/aztec.js/src/api/wallet.ts index ae7d39b316f7..febf62ad7ac2 100644 --- a/yarn-project/aztec.js/src/api/wallet.ts +++ b/yarn-project/aztec.js/src/api/wallet.ts @@ -15,6 +15,7 @@ export { type PublicEvent, type PublicEventFilter, type ContractMetadata, + ContractInitializationStatus, type ContractClassMetadata, AppCapabilitiesSchema, WalletCapabilitiesSchema, diff --git a/yarn-project/aztec.js/src/wallet/wallet.test.ts b/yarn-project/aztec.js/src/wallet/wallet.test.ts index 6ef110d65cd5..e460093b75b1 100644 --- a/yarn-project/aztec.js/src/wallet/wallet.test.ts +++ b/yarn-project/aztec.js/src/wallet/wallet.test.ts @@ -39,7 +39,7 @@ import type { SimulateOptions, Wallet, } from './wallet.js'; -import { WalletSchema } from './wallet.js'; +import { ContractInitializationStatus, WalletSchema } from './wallet.js'; describe('WalletSchema', () => { let handler: MockWallet; @@ -107,7 +107,7 @@ describe('WalletSchema', () => { const result = await context.client.getContractMetadata(await AztecAddress.random()); expect(result).toEqual({ instance: undefined, - isContractInitialized: undefined, + initializationStatus: ContractInitializationStatus.UNKNOWN, isContractPublished: expect.any(Boolean), isContractUpdated: expect.any(Boolean), updatedContractClassId: undefined, @@ -408,7 +408,7 @@ class MockWallet implements Wallet { getContractMetadata(_address: AztecAddress): Promise { return Promise.resolve({ instance: undefined, - isContractInitialized: undefined, + initializationStatus: ContractInitializationStatus.UNKNOWN, isContractPublished: false, isContractUpdated: false, updatedContractClassId: undefined, diff --git a/yarn-project/aztec.js/src/wallet/wallet.ts b/yarn-project/aztec.js/src/wallet/wallet.ts index c827b5977106..efeadb6af6dd 100644 --- a/yarn-project/aztec.js/src/wallet/wallet.ts +++ b/yarn-project/aztec.js/src/wallet/wallet.ts @@ -203,17 +203,27 @@ export type PublicEvent = Event< } >; +/** Whether the contract has been initialized. */ +export enum ContractInitializationStatus { + /** The contract has been initialized (initialization nullifier found). */ + INITIALIZED = 'INITIALIZED', + /** The contract has not been initialized (instance is known, but no initialization nullifier found). */ + UNINITIALIZED = 'UNINITIALIZED', + /** + * Initialization status cannot be determined. The contract instance is not registered in this wallet, so we have + * limited ability to check for initialization. The contract may or may not have been initialized. + */ + UNKNOWN = 'UNKNOWN', +} + /** * Contract metadata including deployment and registration status. */ export type ContractMetadata = { /** The contract instance */ instance?: ContractInstanceWithAddress; - /** - * Whether the contract has been initialized (initialization nullifier exists). - * Undefined when instance is not registered. - */ - isContractInitialized: boolean | undefined; + /** Whether the contract has been initialized. */ + initializationStatus: ContractInitializationStatus; /** Whether the contract instance is publicly deployed on-chain */ isContractPublished: boolean; /** Whether the contract has been updated to a different class */ @@ -377,7 +387,7 @@ export const PublicEventSchema = zodFor>()( export const ContractMetadataSchema = z.object({ instance: optional(ContractInstanceWithAddressSchema), - isContractInitialized: optional(z.boolean()), + initializationStatus: z.nativeEnum(ContractInitializationStatus), isContractPublished: z.boolean(), isContractUpdated: z.boolean(), updatedContractClassId: optional(schemas.Fr), diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts index efe182ad0800..e12d6534354f 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -16,6 +16,7 @@ import { deriveKeys } from '@aztec/aztec.js/keys'; import { createLogger } from '@aztec/aztec.js/log'; import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import { waitForTx } from '@aztec/aztec.js/node'; +import { ContractInitializationStatus } from '@aztec/aztec.js/wallet'; import { createEthereumChain } from '@aztec/ethereum/chain'; import { createExtendedL1Client } from '@aztec/ethereum/client'; import { RollupContract } from '@aztec/ethereum/contracts'; @@ -207,7 +208,7 @@ export class BotFactory { const signingKey = deriveSigningKey(secret); const accountManager = await this.wallet.createSchnorrAccount(secret, salt, signingKey); const metadata = await this.wallet.getContractMetadata(accountManager.address); - if (metadata.isContractInitialized) { + if (metadata.initializationStatus === ContractInitializationStatus.INITIALIZED) { this.log.info(`Account at ${accountManager.address.toString()} already initialized`); const timer = new Timer(); const address = accountManager.address; diff --git a/yarn-project/end-to-end/src/e2e_block_building.test.ts b/yarn-project/end-to-end/src/e2e_block_building.test.ts index 40c58422ce4d..40b2e4ea307f 100644 --- a/yarn-project/end-to-end/src/e2e_block_building.test.ts +++ b/yarn-project/end-to-end/src/e2e_block_building.test.ts @@ -5,6 +5,7 @@ import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { type AztecNode, waitForTx } from '@aztec/aztec.js/node'; import { TxStatus } from '@aztec/aztec.js/tx'; +import { ContractInitializationStatus } from '@aztec/aztec.js/wallet'; import { AnvilTestWatcher, CheatCodes } from '@aztec/aztec/testing'; import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types'; @@ -158,9 +159,9 @@ describe('e2e_block_building', () => { // Assert all contracts got initialized const areInitialized = await Promise.all( - addresses.map(async a => (await wallet.getContractMetadata(a)).isContractInitialized), + addresses.map(async a => (await wallet.getContractMetadata(a)).initializationStatus), ); - expect(areInitialized).toEqual(times(TX_COUNT, () => true)); + expect(areInitialized).toEqual(times(TX_COUNT, () => ContractInitializationStatus.INITIALIZED)); }); it('assembles a block with multiple txs with public fns', async () => { diff --git a/yarn-project/end-to-end/src/e2e_deploy_contract/deploy_method.test.ts b/yarn-project/end-to-end/src/e2e_deploy_contract/deploy_method.test.ts index 0b2dbf226b20..601be528a2ca 100644 --- a/yarn-project/end-to-end/src/e2e_deploy_contract/deploy_method.test.ts +++ b/yarn-project/end-to-end/src/e2e_deploy_contract/deploy_method.test.ts @@ -3,7 +3,7 @@ import { BatchCall } from '@aztec/aztec.js/contracts'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { type AztecNode, createAztecNodeClient } from '@aztec/aztec.js/node'; -import type { Wallet } from '@aztec/aztec.js/wallet'; +import { ContractInitializationStatus, type Wallet } from '@aztec/aztec.js/wallet'; import { TokenContract } from '@aztec/noir-contracts.js/Token'; import { CounterContract } from '@aztec/noir-test-contracts.js/Counter'; import { InitTestContract } from '@aztec/noir-test-contracts.js/InitTest'; @@ -201,8 +201,23 @@ describe('e2e_deploy_contract deploy method', () => { publicCallTxPromise, ]); expect(deployTxReceipt.blockNumber).toEqual(publicCallTxReceipt.blockNumber); + + await t.aztecNodeAdmin.setConfig({ minTxsPerBlock: 1 }); }, 300_000); + it('reports YES for initialization status via public nullifier when instance is not registered', async () => { + const owner = defaultAccountAddress; + const { contract } = await StatefulTestContract.deploy(wallet, owner, 42).send({ from: defaultAccountAddress }); + + // StatefulTestContract has public functions with initialization checks, so during deployment and initialization + // it emits a public initialization nullifier. A wallet without the instance registered falls back to checking + // this nullifier. + const secondWallet = await TestWallet.create(aztecNode); + const metadata = await secondWallet.getContractMetadata(contract.address); + expect(metadata.instance).toBeUndefined(); + expect(metadata.initializationStatus).toEqual(ContractInitializationStatus.INITIALIZED); + }); + describe('regressions', () => { it('fails properly when trying to deploy a contract with a failing constructor with a pxe client with retries', async () => { const { AZTEC_NODE_URL } = process.env; diff --git a/yarn-project/end-to-end/src/e2e_deploy_contract/private_initialization.test.ts b/yarn-project/end-to-end/src/e2e_deploy_contract/private_initialization.test.ts index 5dbe52080cbd..ea5443226953 100644 --- a/yarn-project/end-to-end/src/e2e_deploy_contract/private_initialization.test.ts +++ b/yarn-project/end-to-end/src/e2e_deploy_contract/private_initialization.test.ts @@ -4,6 +4,7 @@ import { publishContractClass, publishInstance } from '@aztec/aztec.js/deploymen import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import type { AztecNode } from '@aztec/aztec.js/node'; +import { ContractInitializationStatus } from '@aztec/aztec.js/wallet'; import { InitTestContract } from '@aztec/noir-test-contracts.js/InitTest'; import { NoConstructorContract } from '@aztec/noir-test-contracts.js/NoConstructor'; import { PrivateInitTestContract } from '@aztec/noir-test-contracts.js/PrivateInitTest'; @@ -224,6 +225,32 @@ describe('e2e_deploy_contract private initialization', () => { ); }); + describe('initialization status', () => { + it('reports INITIALIZED when contract is registered and initialized', async () => { + const contract = await t.registerContract(wallet, PrivateInitTestContract, { + initArgs: [42], + constructorName: 'initialize', + }); + await contract.methods.initialize(42).send({ from: defaultAccountAddress }); + const metadata = await wallet.getContractMetadata(contract.address); + expect(metadata.initializationStatus).toEqual(ContractInitializationStatus.INITIALIZED); + }); + + it('reports UNINITIALIZED when contract is registered but not initialized', async () => { + const contract = await t.registerContract(wallet, PrivateInitTestContract, { + initArgs: [42], + constructorName: 'initialize', + }); + const metadata = await wallet.getContractMetadata(contract.address); + expect(metadata.initializationStatus).toEqual(ContractInitializationStatus.UNINITIALIZED); + }); + + it('reports UNKNOWN when contract instance is not registered', async () => { + const metadata = await wallet.getContractMetadata(await AztecAddress.random()); + expect(metadata.initializationStatus).toEqual(ContractInitializationStatus.UNKNOWN); + }); + }); + /** Registers a contract instance locally and publishes it on-chain (so sequencers can find public function's bytecode). */ async function registerAndPublishContract( initArgs: InitTestCtorArgs, diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index 4b876b9aa659..dc21fe15ac84 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -9,19 +9,20 @@ import { } from '@aztec/aztec.js/contracts'; import type { FeePaymentMethod } from '@aztec/aztec.js/fee'; import { waitForTx } from '@aztec/aztec.js/node'; -import type { - Aliased, - AppCapabilities, - BatchResults, - BatchedMethod, - ExecuteUtilityOptions, - PrivateEvent, - PrivateEventFilter, - ProfileOptions, - SendOptions, - SimulateOptions, - Wallet, - WalletCapabilities, +import { + type Aliased, + type AppCapabilities, + type BatchResults, + type BatchedMethod, + ContractInitializationStatus, + type ExecuteUtilityOptions, + type PrivateEvent, + type PrivateEventFilter, + type ProfileOptions, + type SendOptions, + type SimulateOptions, + type Wallet, + type WalletCapabilities, } from '@aztec/aztec.js/wallet'; import { GAS_ESTIMATION_DA_GAS_LIMIT, @@ -52,7 +53,10 @@ import { } from '@aztec/stdlib/contract'; import { SimulationError } from '@aztec/stdlib/errors'; import { Gas, GasSettings } from '@aztec/stdlib/gas'; -import { computeSiloedPrivateInitializationNullifier } from '@aztec/stdlib/hash'; +import { + computeSiloedPrivateInitializationNullifier, + computeSiloedPublicInitializationNullifier, +} from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { BlockHeader, @@ -494,26 +498,29 @@ export abstract class BaseWallet implements Wallet { /** * Returns metadata about a contract, including whether it has been initialized, published, and updated. - * - * `isContractInitialized` requires the contract instance to be registered in the PXE (for `init_hash`). When the - * instance is not available, `isContractInitialized` is `undefined` since it cannot be determined. * @param address - The contract address to query. */ async getContractMetadata(address: AztecAddress) { const instance = await this.pxe.getContractInstance(address); const publiclyRegisteredContractPromise = this.aztecNode.getContract(address); - // We check only the private initialization nullifier. It is emitted by both private and public initializers and - // includes init_hash, preventing observers from determining initialization status from the address alone. Without - // the instance (and thus init_hash), we can't compute it, so we return undefined. - // - // We skip the public initialization nullifier because it's not always emitted (contracts without public external - // functions that require initialization checks won't emit it). If the private one exists, the public one was - // created in the same tx and will also be present. - let isContractInitialized: boolean | undefined = undefined; + + let initializationStatus: ContractInitializationStatus; if (instance) { + // We have the instance, so we can compute the private initialization nullifier (which includes init_hash and is + // emitted by both private and public initializers) and get a definitive INITIALIZED/UNINITIALIZED answer. const initNullifier = await computeSiloedPrivateInitializationNullifier(address, instance.initializationHash); const witness = await this.aztecNode.getNullifierMembershipWitness('latest', initNullifier); - isContractInitialized = !!witness; + initializationStatus = witness + ? ContractInitializationStatus.INITIALIZED + : ContractInitializationStatus.UNINITIALIZED; + } else { + // Without the instance we lack the init_hash needed for the private nullifier. We fall back to checking the + // public initialization nullifier (computed from address alone). Not all contracts emit it (only those with + // public functions that require initialization checks), so its absence doesn't mean the contract is + // uninitialized. + const publicNullifier = await computeSiloedPublicInitializationNullifier(address); + const witness = await this.aztecNode.getNullifierMembershipWitness('latest', publicNullifier); + initializationStatus = witness ? ContractInitializationStatus.INITIALIZED : ContractInitializationStatus.UNKNOWN; } const publiclyRegisteredContract = await publiclyRegisteredContractPromise; const isContractUpdated = @@ -521,7 +528,7 @@ export abstract class BaseWallet implements Wallet { !publiclyRegisteredContract.currentContractClassId.equals(publiclyRegisteredContract.originalContractClassId); return { instance: instance ?? undefined, - isContractInitialized, + initializationStatus, isContractPublished: !!publiclyRegisteredContract, isContractUpdated: !!isContractUpdated, updatedContractClassId: isContractUpdated ? publiclyRegisteredContract.currentContractClassId : undefined, From a0a4f08f92f9eea5aed8002e7792df1a03001a3f Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 19 Mar 2026 18:57:48 -0300 Subject: [PATCH 02/10] fix(stdlib): zero-pad bufferFromFields when declared length exceeds payload (#21802) Ensures that `bufferFromFields` always returns a buffer with the length requested in the first field of the array. This protects against this method being called with a truncated array, which could cause a wrong public bytecode commitment to be computed. Note that this is currently not the case, since this function always gets called with an array that's exactly `CONTRACT_CLASS_LOG_SIZE_IN_FIELDS` long, which is greater than the `MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS`. Co-authored-by: Claude Opus 4.6 (1M context) --- yarn-project/stdlib/src/abi/buffer.test.ts | 42 ++++++++++++++++++++++ yarn-project/stdlib/src/abi/buffer.ts | 29 ++++++++++++--- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/yarn-project/stdlib/src/abi/buffer.test.ts b/yarn-project/stdlib/src/abi/buffer.test.ts index 9c3329666c30..d4ca635d5b92 100644 --- a/yarn-project/stdlib/src/abi/buffer.test.ts +++ b/yarn-project/stdlib/src/abi/buffer.test.ts @@ -1,3 +1,5 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; + import { bufferAsFields, bufferFromFields } from './buffer.js'; describe('buffer', () => { @@ -11,4 +13,44 @@ describe('buffer', () => { const buffer = Buffer.from('1234567890abcdef'.repeat(10), 'hex'); expect(() => bufferAsFields(buffer, 3)).toThrow(/exceeds maximum size/); }); + + it('pads with zeros when declared length exceeds payload', () => { + // Create a small buffer, encode it, then truncate the field array before decoding. + const buffer = Buffer.from('aabbccdd', 'hex'); // 4 bytes + const fields = bufferAsFields(buffer, 10); + // Declared length is 4 bytes, stored in fields[0]. Payload fields follow. + // Artificially inflate the declared length to 62 bytes (2 full fields). + + const inflatedFields = [new Fr(62), ...fields.slice(1)]; + const result = bufferFromFields(inflatedFields); + // Result should be exactly 62 bytes: original 4 bytes followed by 58 zero bytes. + expect(result.length).toBe(62); + expect(result.subarray(0, 4).toString('hex')).toEqual('aabbccdd'); + expect(result.subarray(4).every(b => b === 0)).toBe(true); + }); + + it('pads with zeros when payload fields are truncated', () => { + // Simulate the blob reconstruction scenario: declared length says 93 bytes (3 fields), + // but only 1 payload field is present. + + const payloadField = Fr.fromBuffer( + Buffer.from('00' + 'ab'.repeat(31), 'hex'), // 31 bytes of 0xab + ); + // Declared length = 93 bytes (would need 3 fields), but only 1 field in payload. + const fields = [new Fr(93), payloadField]; + const result = bufferFromFields(fields); + expect(result.length).toBe(93); + // First 31 bytes come from the single payload field. + expect(result.subarray(0, 31).every(b => b === 0xab)).toBe(true); + // Remaining 62 bytes are zero-padded. + expect(result.subarray(31).every(b => b === 0)).toBe(true); + }); + + it('returns exact buffer when payload matches declared length', () => { + const buffer = Buffer.from('ff'.repeat(31), 'hex'); // exactly 1 field of payload + const fields = bufferAsFields(buffer, 5); + const result = bufferFromFields(fields); + expect(result.length).toBe(31); + expect(result.toString('hex')).toEqual(buffer.toString('hex')); + }); }); diff --git a/yarn-project/stdlib/src/abi/buffer.ts b/yarn-project/stdlib/src/abi/buffer.ts index 075143244254..42d4ea8eba8a 100644 --- a/yarn-project/stdlib/src/abi/buffer.ts +++ b/yarn-project/stdlib/src/abi/buffer.ts @@ -26,11 +26,32 @@ export function bufferAsFields(input: Buffer, targetLength: number): Fr[] { } /** - * Recovers a buffer from an array of fields. - * @param fields - An output from bufferAsFields. - * @returns The recovered buffer. + * Recovers a buffer from an array of fields previously encoded with bufferAsFields. + * + * The first field encodes the byte length of the original buffer. The remaining fields + * each carry 31 bytes of payload (the leading byte of each 32-byte field element is skipped). + * + * If the declared byte length exceeds the bytes available from the payload fields, the result + * is zero-padded to the full declared length. This is important for correctness when the field + * array has been truncated (e.g. contract class logs reconstructed from blobs using a short + * emittedLength): without padding, the resulting buffer would be shorter than declared, causing + * bytecode commitment computations to diverge from what the circuit produced. + * + * @param fields - An output from bufferAsFields: [byteLength, ...payloadFields]. + * @returns A buffer of exactly `byteLength` bytes. */ export function bufferFromFields(fields: Fr[]): Buffer { const [length, ...payload] = fields; - return Buffer.concat(payload.map(f => f.toBuffer().subarray(1))).subarray(0, length.toNumber()); + const byteLength = length.toNumber(); + const raw = Buffer.concat(payload.map(f => f.toBuffer().subarray(1))); + if (raw.length >= byteLength) { + return raw.subarray(0, byteLength); + } + // Pad with zeros if the declared length exceeds the available payload bytes. + // This ensures the returned buffer always matches the declared length, so that + // downstream bytecode commitment computations are consistent even when the + // source field array was truncated (e.g. reconstructed from blob with a short emittedLength). + const result = Buffer.alloc(byteLength); + raw.copy(result); + return result; } From 10828afa303af4b637761320647261e9d9a637a5 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 19 Mar 2026 19:26:57 -0300 Subject: [PATCH 03/10] test(protocol-contracts): verify max-size bytecode fits in contract class log (#21818) Verify that `CONTRACT_CLASS_LOG_SIZE_IN_FIELDS` is large enough to hold a max-size public bytecode alongside the `ContractClassPublishedEvent` header fields. If these constants drift, contract class registration could silently break. Co-authored-by: Claude Opus 4.6 (1M context) --- .../contract_class_published_event.test.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.test.ts b/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.test.ts index 860533d58fe0..8647fc333f26 100644 --- a/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.test.ts +++ b/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.test.ts @@ -1,11 +1,20 @@ +import { + CONTRACT_CLASS_LOG_SIZE_IN_FIELDS, + CONTRACT_CLASS_PUBLISHED_MAGIC_VALUE, + MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS, +} from '@aztec/constants'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { setupCustomSnapshotSerializers } from '@aztec/foundation/testing'; -import { ContractClassLog } from '@aztec/stdlib/logs'; +import { bufferAsFields } from '@aztec/stdlib/abi'; +import { ContractClassLog, ContractClassLogFields } from '@aztec/stdlib/logs'; +import { ProtocolContractAddress } from '../protocol_contract_data.js'; import { getSampleContractClassPublishedEventPayload } from '../tests/fixtures.js'; import { ContractClassPublishedEvent } from './contract_class_published_event.js'; describe('ContractClassPublishedEvent', () => { beforeAll(() => setupCustomSnapshotSerializers(expect)); + it('parses an event as emitted by the ContractClassRegistry', () => { const log = ContractClassLog.fromBuffer(getSampleContractClassPublishedEventPayload()); expect(ContractClassPublishedEvent.isContractClassPublishedEvent(log)).toBe(true); @@ -15,4 +24,42 @@ describe('ContractClassPublishedEvent', () => { // See ./__snapshots__/README.md for how to update the snapshot. expect(event).toMatchSnapshot(); }); + + it('fits a max-size public bytecode within CONTRACT_CLASS_LOG_SIZE_IN_FIELDS', () => { + // Create a bytecode that fills the maximum allowed size. + // bufferAsFields encodes as [length_in_bytes, ...31-byte-chunks, ...zero-padding] up to the target length. + // The max bytecode in bytes is (MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS - 1) * 31, + // since one field is used for the length prefix. + const maxBytecodeBytes = (MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS - 1) * 31; + const maxBytecode = Buffer.alloc(maxBytecodeBytes, 0xab); + + // Encode the bytecode as fields (same encoding used in the Noir contract). + const bytecodeFields = bufferAsFields(maxBytecode, MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS); + expect(bytecodeFields).toHaveLength(MAX_PACKED_PUBLIC_BYTECODE_SIZE_IN_FIELDS); + + // The event header: [magic, contractClassId, version, artifactHash, privateFunctionsRoot] + const headerFields = [ + new Fr(CONTRACT_CLASS_PUBLISHED_MAGIC_VALUE), + Fr.random(), // contractClassId + new Fr(1), // version + Fr.random(), // artifactHash + Fr.random(), // privateFunctionsRoot + ]; + + // This is the main assertion: the CONTRACT_CLASS_LOG_SIZE_IN_FIELDS is enough such that + // a max-size bytecode can be included in the event log together with the header fields + const totalFields = headerFields.length + bytecodeFields.length; + expect(totalFields).toBeLessThanOrEqual(CONTRACT_CLASS_LOG_SIZE_IN_FIELDS); + + // Verify it round-trips through ContractClassLog and ContractClassPublishedEvent. + const allFields = [...headerFields, ...bytecodeFields]; + const padded = [...allFields, ...Array(CONTRACT_CLASS_LOG_SIZE_IN_FIELDS - allFields.length).fill(Fr.ZERO)]; + const logFields = new ContractClassLogFields(padded); + const log = new ContractClassLog(ProtocolContractAddress.ContractClassRegistry, logFields, allFields.length); + + expect(ContractClassPublishedEvent.isContractClassPublishedEvent(log)).toBe(true); + + const event = ContractClassPublishedEvent.fromLog(log); + expect(event.packedPublicBytecode).toEqual(maxBytecode); + }); }); From c9513dd65e080b7259fbed14225de169285396cc Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:56:31 -0400 Subject: [PATCH 04/10] chore: port P2P mesh topic deflake fix to v4-next (#21825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Ports the P2P mesh connectivity fix from `next` to `v4-next` for the `duplicate_attestation_slash` and `duplicate_proposal_slash` e2e tests. Cherry-picked commit: `8680abcca7` — chore: deflake duplicate proposals and attestations (#20990) ## Root Cause `waitForP2PMeshConnectivity` only waited for the `tx` GossipSub topic mesh to form. The slash tests also need `block_proposal` and `checkpoint_proposal` meshes ready before sequencers start proposing, otherwise proposals get dropped and offenses are never detected. ## Fix - Added `topics` parameter to `waitForP2PMeshConnectivity` (defaults to `[TopicType.tx]` for backward compat) - Slash tests now wait for all 3 relevant topics before proceeding - Also added `advanceToEpochBeforeProposer` helper so sequencers start before the target epoch arrives Co-authored-by: Michal Rzeszutko --- .../duplicate_attestation_slash.test.ts | 37 ++++++++++++--- .../e2e_p2p/duplicate_proposal_slash.test.ts | 37 ++++++++++++--- .../end-to-end/src/e2e_p2p/p2p_network.ts | 39 ++++++++-------- yarn-project/end-to-end/src/e2e_p2p/shared.ts | 45 ++++++++++++++++++- 4 files changed, 127 insertions(+), 31 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts index 2f68d908d458..3ebef6ac94da 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { P2PNetworkTest } from './p2p_network.js'; -import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; +import { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -141,6 +142,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase1, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 1, @@ -159,6 +161,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase2, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 2, @@ -172,7 +175,10 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { // Create honest nodes with unique validator keys (indices 1 and 2) t.logger.warn('Creating honest nodes'); const honestNode1 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 3, t.bootstrapNodeEnr, @@ -182,7 +188,10 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -194,10 +203,27 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousProposerAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // Wait for offenses to be detected // We expect BOTH duplicate proposal AND duplicate attestation offenses // The malicious proposer nodes create duplicate proposals (same key, different coinbase) @@ -236,7 +262,6 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { } // Verify that for each duplicate attestation offense, the attester for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; for (const offense of duplicateAttestationOffenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const committeeInfo = await epochCache.getCommittee(offenseSlot); diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts index 374e4527d4ef..c0b6062acac6 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { P2PNetworkTest } from './p2p_network.js'; -import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; +import { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -130,6 +131,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase1, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 1, @@ -147,6 +149,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase2, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 2, @@ -160,7 +163,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { // Create honest nodes with unique validator keys (indices 1 and 2) t.logger.warn('Creating honest nodes'); const honestNode1 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 3, t.bootstrapNodeEnr, @@ -170,7 +176,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -182,10 +191,27 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousValidatorAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // Wait for offense to be detected // The honest nodes should detect the duplicate proposal from the malicious validator t.logger.warn('Waiting for duplicate proposal offense to be detected...'); @@ -208,7 +234,6 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { } // Verify that for each offense, the proposer for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; for (const offense of offenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); diff --git a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts index 1f2120f28177..83e18d1bc4ea 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts @@ -408,6 +408,7 @@ export class P2PNetworkTest { expectedNodeCount?: number, timeoutSeconds = 30, checkIntervalSeconds = 0.1, + topics: TopicType[] = [TopicType.tx], ) { const nodeCount = expectedNodeCount ?? nodes.length; const minPeerCount = nodeCount - 1; @@ -434,26 +435,28 @@ export class P2PNetworkTest { this.logger.warn('All nodes connected to P2P mesh'); - // Wait for GossipSub mesh to form for the tx topic. + // Wait for GossipSub mesh to form for all specified topics. // We only require at least 1 mesh peer per node because GossipSub // stops grafting once it reaches Dlo peers and won't fill the mesh to all available peers. - this.logger.warn('Waiting for GossipSub mesh to form for tx topic...'); - await Promise.all( - nodes.map(async (node, index) => { - const p2p = node.getP2P(); - await retryUntil( - async () => { - const meshPeers = await p2p.getGossipMeshPeerCount(TopicType.tx); - this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for tx topic`); - return meshPeers >= 1 ? true : undefined; - }, - `Node ${index} to have gossip mesh peers for tx topic`, - timeoutSeconds, - checkIntervalSeconds, - ); - }), - ); - this.logger.warn('All nodes have gossip mesh peers for tx topic'); + for (const topic of topics) { + this.logger.warn(`Waiting for GossipSub mesh to form for ${topic} topic...`); + await Promise.all( + nodes.map(async (node, index) => { + const p2p = node.getP2P(); + await retryUntil( + async () => { + const meshPeers = await p2p.getGossipMeshPeerCount(topic); + this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for ${topic} topic`); + return meshPeers >= 1 ? true : undefined; + }, + `Node ${index} to have gossip mesh peers for ${topic} topic`, + timeoutSeconds, + checkIntervalSeconds, + ); + }), + ); + this.logger.warn(`All nodes have gossip mesh peers for ${topic} topic`); + } } async teardown() { diff --git a/yarn-project/end-to-end/src/e2e_p2p/shared.ts b/yarn-project/end-to-end/src/e2e_p2p/shared.ts index a74488fef1c0..656313537ec8 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/shared.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/shared.ts @@ -6,12 +6,13 @@ import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { TxHash } from '@aztec/aztec.js/tx'; import type { RollupCheatCodes } from '@aztec/aztec/testing'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; import type { EmpireSlashingProposerContract, RollupContract, TallySlashingProposerContract, } from '@aztec/ethereum/contracts'; -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { timesAsync, unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { retryUntil } from '@aztec/foundation/retry'; @@ -150,6 +151,48 @@ export async function awaitCommitteeExists({ return committee!.map(c => c.toString() as `0x${string}`); } +/** + * Advance epochs until we find one where the target proposer is selected for at least one slot. + * With N validators and M slots per epoch, a specific proposer may not be selected in any given epoch. + * For example, with 4 validators and 2 slots/epoch, there is about a 44% chance per epoch. + */ +export async function awaitEpochWithProposer({ + epochCache, + cheatCodes, + targetProposer, + logger, + maxAttempts = 20, +}: { + epochCache: EpochCacheInterface; + cheatCodes: RollupCheatCodes; + targetProposer: EthAddress; + logger: Logger; + maxAttempts?: number; +}): Promise { + const { epochDuration } = await cheatCodes.getConfig(); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const currentEpoch = await cheatCodes.getEpoch(); + const startSlot = Number(currentEpoch) * Number(epochDuration); + const endSlot = startSlot + Number(epochDuration); + + logger.info(`Checking epoch ${currentEpoch} (slots ${startSlot}-${endSlot - 1}) for proposer ${targetProposer}`); + + for (let s = startSlot; s < endSlot; s++) { + const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s)); + if (proposer && proposer.equals(targetProposer)) { + logger.warn(`Found target proposer ${targetProposer} in slot ${s} of epoch ${currentEpoch}`); + return; + } + } + + logger.info(`Target proposer not found in epoch ${currentEpoch}, advancing to next epoch`); + await cheatCodes.advanceToNextEpoch(); + } + + throw new Error(`Target proposer ${targetProposer} not found in any slot after ${maxAttempts} epoch attempts`); +} + export async function awaitOffenseDetected({ logger, nodeAdmin, From 139da7d9e7f494361cdf902f28ff13e4e157f106 Mon Sep 17 00:00:00 2001 From: PhilWindle <60546371+PhilWindle@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:24:49 +0000 Subject: [PATCH 05/10] fix(archiver): throw on duplicate contract class or instance additions (#21799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation The contract instance store used `.set()` which silently overwrote existing entries, while the contract class store used `.setIfNotExists()` which silently ignored duplicates. Neither behavior catches the unexpected case of a double-add, which could lead to data loss on rollback — if an instance is added at block N and again at block M, rolling back block M would delete the instance entirely, invalidating the first add. The protocol prevents this via deployer nullifiers, but the store should enforce it as defense-in-depth. ## Approach Both `addContractInstance` and `addContractClass` now check for existing entries and throw if the key already exists. This surfaces any unexpected double-adds as errors rather than silently corrupting state. ## Changes - **archiver (contract_instance_store)**: `addContractInstance` checks `hasAsync` before writing; throws with a descriptive error on duplicate - **archiver (contract_class_store)**: `addContractClass` replaces `setIfNotExists` with explicit `hasAsync` check and throw on duplicate - **archiver (tests)**: Updated "add twice" tests to expect throws instead of silent success --- yarn-project/archiver/src/factory.ts | 8 +++- .../src/store/contract_class_store.ts | 10 +++-- .../src/store/contract_instance_store.ts | 13 +++--- .../src/store/kv_archiver_store.test.ts | 41 +++++++------------ 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index bbf2f8d56948..f056d687296f 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -175,12 +175,18 @@ export async function createArchiver( return archiver; } -/** Registers protocol contracts in the archiver store. */ +/** Registers protocol contracts in the archiver store. Idempotent — skips contracts that already exist (e.g. on node restart). */ export async function registerProtocolContracts(store: KVArchiverDataStore) { const blockNumber = 0; for (const name of protocolContractNames) { const provider = new BundledProtocolContractsProvider(); const contract = await provider.getProtocolContractArtifact(name); + + // Skip if already registered (happens on node restart with a persisted store). + if (await store.getContractClass(contract.contractClass.id)) { + continue; + } + const contractClassPublic: ContractClassPublic = { ...contract.contractClass, privateFunctions: [], diff --git a/yarn-project/archiver/src/store/contract_class_store.ts b/yarn-project/archiver/src/store/contract_class_store.ts index 36de477aad42..ebe0d6f9400f 100644 --- a/yarn-project/archiver/src/store/contract_class_store.ts +++ b/yarn-project/archiver/src/store/contract_class_store.ts @@ -29,11 +29,15 @@ export class ContractClassStore { blockNumber: number, ): Promise { await this.db.transactionAsync(async () => { - await this.#contractClasses.setIfNotExists( - contractClass.id.toString(), + const key = contractClass.id.toString(); + if (await this.#contractClasses.hasAsync(key)) { + throw new Error(`Contract class ${key} already exists, cannot add again at block ${blockNumber}`); + } + await this.#contractClasses.set( + key, serializeContractClassPublic({ ...contractClass, l2BlockNumber: blockNumber }), ); - await this.#bytecodeCommitments.setIfNotExists(contractClass.id.toString(), bytecodeCommitment.toBuffer()); + await this.#bytecodeCommitments.set(key, bytecodeCommitment.toBuffer()); }); } diff --git a/yarn-project/archiver/src/store/contract_instance_store.ts b/yarn-project/archiver/src/store/contract_instance_store.ts index 63ea37ff9bcb..332605240e04 100644 --- a/yarn-project/archiver/src/store/contract_instance_store.ts +++ b/yarn-project/archiver/src/store/contract_instance_store.ts @@ -27,11 +27,14 @@ export class ContractInstanceStore { addContractInstance(contractInstance: ContractInstanceWithAddress, blockNumber: number): Promise { return this.db.transactionAsync(async () => { - await this.#contractInstances.set( - contractInstance.address.toString(), - new SerializableContractInstance(contractInstance).toBuffer(), - ); - await this.#contractInstancePublishedAt.set(contractInstance.address.toString(), blockNumber); + const key = contractInstance.address.toString(); + if (await this.#contractInstances.hasAsync(key)) { + throw new Error( + `Contract instance at ${key} already exists (deployed at block ${await this.#contractInstancePublishedAt.getAsync(key)}), cannot add again at block ${blockNumber}`, + ); + } + await this.#contractInstances.set(key, new SerializableContractInstance(contractInstance).toBuffer()); + await this.#contractInstancePublishedAt.set(key, blockNumber); }); } diff --git a/yarn-project/archiver/src/store/kv_archiver_store.test.ts b/yarn-project/archiver/src/store/kv_archiver_store.test.ts index 3143da999025..5b7c188ccf78 100644 --- a/yarn-project/archiver/src/store/kv_archiver_store.test.ts +++ b/yarn-project/archiver/src/store/kv_archiver_store.test.ts @@ -2201,14 +2201,14 @@ describe('KVArchiverDataStore', () => { await expect(store.getContractClass(contractClass.id)).resolves.toBeUndefined(); }); - it('returns contract class if later "deployment" class was deleted', async () => { - await store.addContractClasses( - [contractClass], - [await computePublicBytecodeCommitment(contractClass.packedBytecode)], - BlockNumber(blockNum + 1), - ); - await store.deleteContractClasses([contractClass], BlockNumber(blockNum + 1)); - await expect(store.getContractClass(contractClass.id)).resolves.toMatchObject(contractClass); + it('throws if the same contract class is added again', async () => { + await expect( + store.addContractClasses( + [contractClass], + [await computePublicBytecodeCommitment(contractClass.packedBytecode)], + BlockNumber(blockNum + 1), + ), + ).rejects.toThrow(/already exists/); }); it('returns undefined if contract class is not found', async () => { @@ -3089,22 +3089,17 @@ describe('KVArchiverDataStore', () => { expect(storedBlock?.archive.root.equals(provisionalBlock.archive.root)).toBe(true); }); - it('does not throw when adding the same contract class twice', async () => { + it('throws when adding the same contract class twice', async () => { const contractClass = await makeContractClassPublic(); const commitment = await computePublicBytecodeCommitment(contractClass.packedBytecode); - // Add contract class first time await store.addContractClasses([contractClass], [commitment], BlockNumber(1)); - - // Add same contract class again - should not throw (uses setIfNotExists) - await store.addContractClasses([contractClass], [commitment], BlockNumber(2)); - - // Verify contract class exists - const retrieved = await store.getContractClass(contractClass.id); - expect(retrieved).toBeDefined(); + await expect(store.addContractClasses([contractClass], [commitment], BlockNumber(2))).rejects.toThrow( + /already exists/, + ); }); - it('does not throw when adding the same contract instance twice', async () => { + it('throws when adding the same contract instance twice', async () => { const contractClass = await makeContractClassPublic(); await store.addContractClasses( [contractClass], @@ -3120,16 +3115,8 @@ describe('KVArchiverDataStore', () => { address: await AztecAddress.random(), }; - // Add contract instance first time await store.addContractInstances([instance], BlockNumber(1)); - - // Add same contract instance again - should not throw (uses set) - await store.addContractInstances([instance], BlockNumber(2)); - - // Verify instance exists - const retrieved = await store.getContractInstance(instance.address, 1000n); - expect(retrieved).toBeDefined(); - expect(retrieved?.address.equals(instance.address)).toBe(true); + await expect(store.addContractInstances([instance], BlockNumber(2))).rejects.toThrow(/already exists/); }); it('does not duplicate logs when addLogs is called twice with same block', async () => { From d9b251db18f1270788ab2f404ff2e6d95f263d9e Mon Sep 17 00:00:00 2001 From: Gregorio Juliana Date: Fri, 20 Mar 2026 11:24:33 +0100 Subject: [PATCH 06/10] feat: sync poseidon in the browser (#21833) https://github.com/AztecProtocol/aztec-packages/pull/20826 completely tanks performance in the browser. PXE does a lot of hashing and the `SharedArrayBuffer` comms overhead is way too much. This PR reverts to the old behavior only in the browser. --- .../src/crypto/poseidon/index.test.ts | 4 +- .../foundation/src/crypto/poseidon/index.ts | 76 ++++++++++--------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/yarn-project/foundation/src/crypto/poseidon/index.test.ts b/yarn-project/foundation/src/crypto/poseidon/index.test.ts index 84b6f39d6e06..abf4af662133 100644 --- a/yarn-project/foundation/src/crypto/poseidon/index.test.ts +++ b/yarn-project/foundation/src/crypto/poseidon/index.test.ts @@ -1,11 +1,11 @@ -import { Barretenberg } from '@aztec/bb.js'; +import { BarretenbergSync } from '@aztec/bb.js'; import { Fr } from '../../curves/bn254/field.js'; import { poseidon2Permutation } from './index.js'; describe('poseidon2Permutation', () => { beforeAll(async () => { - await Barretenberg.initSingleton({ threads: 1 }); + await BarretenbergSync.initSingleton(); }); it('test vectors from cpp should match', async () => { diff --git a/yarn-project/foundation/src/crypto/poseidon/index.ts b/yarn-project/foundation/src/crypto/poseidon/index.ts index 601a1a000b65..adbd7a4b4eb4 100644 --- a/yarn-project/foundation/src/crypto/poseidon/index.ts +++ b/yarn-project/foundation/src/crypto/poseidon/index.ts @@ -1,21 +1,35 @@ -import { Barretenberg } from '@aztec/bb.js'; +import { Barretenberg, BarretenbergSync } from '@aztec/bb.js'; import { Fr } from '../../curves/bn254/field.js'; import { type Fieldable, serializeToFields } from '../../serialize/serialize.js'; +const IS_BROWSER = typeof window !== 'undefined'; + +async function poseidon2HashFields(inputFields: Fr[]): Promise { + if (IS_BROWSER) { + await BarretenbergSync.initSingleton(); + const api = BarretenbergSync.getSingleton(); + const response = api.poseidon2Hash({ + inputs: inputFields.map(i => i.toBuffer()), + }); + return Fr.fromBuffer(Buffer.from(response.hash)); + } else { + await Barretenberg.initSingleton(); + const api = Barretenberg.getSingleton(); + const response = await api.poseidon2Hash({ + inputs: inputFields.map(i => i.toBuffer()), + }); + return Fr.fromBuffer(Buffer.from(response.hash)); + } +} + /** * Create a poseidon hash (field) from an array of input fields. * @param input - The input fields to hash. * @returns The poseidon hash. */ -export async function poseidon2Hash(input: Fieldable[]): Promise { - const inputFields = serializeToFields(input); - await Barretenberg.initSingleton(); - const api = Barretenberg.getSingleton(); - const response = await api.poseidon2Hash({ - inputs: inputFields.map(i => i.toBuffer()), - }); - return Fr.fromBuffer(Buffer.from(response.hash)); +export function poseidon2Hash(input: Fieldable[]): Promise { + return poseidon2HashFields(serializeToFields(input)); } /** @@ -24,15 +38,10 @@ export async function poseidon2Hash(input: Fieldable[]): Promise { * @param separator - The domain separator. * @returns The poseidon hash. */ -export async function poseidon2HashWithSeparator(input: Fieldable[], separator: number): Promise { +export function poseidon2HashWithSeparator(input: Fieldable[], separator: number): Promise { const inputFields = serializeToFields(input); inputFields.unshift(new Fr(separator)); - await Barretenberg.initSingleton(); - const api = Barretenberg.getSingleton(); - const response = await api.poseidon2Hash({ - inputs: inputFields.map(i => i.toBuffer()), - }); - return Fr.fromBuffer(Buffer.from(response.hash)); + return poseidon2HashFields(inputFields); } /** @@ -42,19 +51,24 @@ export async function poseidon2HashWithSeparator(input: Fieldable[], separator: */ export async function poseidon2Permutation(input: Fieldable[]): Promise { const inputFields = serializeToFields(input); - // We'd like this assertion but it's not possible to use it in the browser. - // assert(input.length === 4, 'Input state must be of size 4'); - await Barretenberg.initSingleton(); - const api = Barretenberg.getSingleton(); - const response = await api.poseidon2Permutation({ - inputs: inputFields.map(i => i.toBuffer()), - }); - // We'd like this assertion but it's not possible to use it in the browser. - // assert(response.outputs.length === 4, 'Output state must be of size 4'); - return response.outputs.map(o => Fr.fromBuffer(Buffer.from(o))); + if (IS_BROWSER) { + await BarretenbergSync.initSingleton(); + const api = BarretenbergSync.getSingleton(); + const response = api.poseidon2Permutation({ + inputs: inputFields.map(i => i.toBuffer()), + }); + return response.outputs.map(o => Fr.fromBuffer(Buffer.from(o))); + } else { + await Barretenberg.initSingleton(); + const api = Barretenberg.getSingleton(); + const response = await api.poseidon2Permutation({ + inputs: inputFields.map(i => i.toBuffer()), + }); + return response.outputs.map(o => Fr.fromBuffer(Buffer.from(o))); + } } -export async function poseidon2HashBytes(input: Buffer): Promise { +export function poseidon2HashBytes(input: Buffer): Promise { const inputFields = []; for (let i = 0; i < input.length; i += 31) { const fieldBytes = Buffer.alloc(32, 0); @@ -65,11 +79,5 @@ export async function poseidon2HashBytes(input: Buffer): Promise { inputFields.push(Fr.fromBuffer(fieldBytes)); } - await Barretenberg.initSingleton(); - const api = Barretenberg.getSingleton(); - const response = await api.poseidon2Hash({ - inputs: inputFields.map(i => i.toBuffer()), - }); - - return Fr.fromBuffer(Buffer.from(response.hash)); + return poseidon2HashFields(inputFields); } From c0b21acc48c2261a1fb57940e06a3017294e034d Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:54:03 -0400 Subject: [PATCH 07/10] chore: backport #21824 (fix(aztec-up): add sensible defaults to installer y/n prompts) to v4-next (#21844) --- aztec-up/bin/0.0.1/aztec-install | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aztec-up/bin/0.0.1/aztec-install b/aztec-up/bin/0.0.1/aztec-install index a217e7b134d4..b4074bb8fbef 100755 --- a/aztec-up/bin/0.0.1/aztec-install +++ b/aztec-up/bin/0.0.1/aztec-install @@ -58,10 +58,10 @@ function title { echo -e " ${bold}${g}aztec-up${r} - a tool to install and manage aztec toolchain versions." echo -e " ${bold}${g}aztec-wallet${r} - our minimalistic CLI wallet" echo - read -p "Do you wish to continue? (y/n) " -n 1 -r + read -p "Do you wish to continue? (Y/n) " -n 1 -r echo echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then + if [[ ! $REPLY =~ ^[Yy]?$ ]]; then exit 1 fi } @@ -83,7 +83,7 @@ function check_for_old_install { echo_yellow "If you continue, the entire $AZTEC_HOME directory will be removed and replaced with the new installation." echo "You should manually remove old docker images you no longer need." echo - read -p "Do you wish to continue? (y/n) " -n 1 -r + read -p "Do you wish to continue? (y/N) " -n 1 -r echo echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then From 026e17b0e4434953b0d50f25d74dc0194d525dfe Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:25:26 -0400 Subject: [PATCH 08/10] fix(sequencer): backport wall-clock time for slot estimation to v4-next (#21769) (#21847) ## Summary Backport of https://github.com/AztecProtocol/aztec-packages/pull/21769 to v4-next. When an Ethereum slot is missed (no block produced), the next L1 block lands 24+ seconds after the previous one instead of 12. Computing the next block timestamp as `getBlock().timestamp + ethereumSlotDuration` produces wrong results, causing incorrect L2 slot, committee, and proposer calculations. This backport replaces all `getBlock().timestamp + ethereumSlotDuration` patterns with a new `getNextL1SlotTimestamp()` helper that rounds wall-clock time up to the next L1 slot boundary. Additionally, `getSyncedL2SlotNumber` now uses the latest synced checkpoint slot as a second signal to determine sync progress. ## Changes from original PR adapted for v4-next - Cherry-pick had 4 conflicted files (epoch_cache, rollup, sequencer-publisher, publisher test) - Removed `getTargetEpochAndSlotInNextL1Slot` (pipelining not present on v4-next) - Removed publisher rotation tests (feature not present on v4-next) - Used `dateProvider.nowInSeconds()` directly instead of removed `EpochCache.nowInSeconds()` method - Renamed `canProposeAtNextEthBlock` to `canProposeAt` on RollupContract and SequencerPublisher Fixes #14766 ClaudeBox log: https://claudebox.work/s/76e2a4f0177d755a?run=1 --- .../.claude/rules/typescript-style.md | 18 ++- yarn-project/archiver/src/archiver.ts | 34 ++-- .../aztec-node/src/aztec-node/server.ts | 16 +- .../e2e_epochs/epochs_missed_l1_slot.test.ts | 152 ++++++++++++++++++ .../e2e_l1_publisher/e2e_l1_publisher.test.ts | 12 +- yarn-project/epoch-cache/src/epoch_cache.ts | 16 +- yarn-project/ethereum/src/contracts/rollup.ts | 12 +- .../foundation/src/branded-types/slot.ts | 5 + .../src/client/sequencer-client.ts | 14 +- .../global_variable_builder/global_builder.ts | 46 +++--- .../src/global_variable_builder/index.ts | 2 +- .../src/publisher/sequencer-publisher.test.ts | 2 + .../src/publisher/sequencer-publisher.ts | 30 +++- .../src/sequencer/sequencer.test.ts | 12 +- .../src/sequencer/sequencer.ts | 6 +- .../stdlib/src/block/l2_block_source.ts | 6 +- .../stdlib/src/epoch-helpers/index.ts | 11 ++ 17 files changed, 304 insertions(+), 90 deletions(-) create mode 100644 yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts diff --git a/yarn-project/.claude/rules/typescript-style.md b/yarn-project/.claude/rules/typescript-style.md index 5ea500e2e736..0990cc1f0c00 100644 --- a/yarn-project/.claude/rules/typescript-style.md +++ b/yarn-project/.claude/rules/typescript-style.md @@ -330,4 +330,20 @@ mock.getData.mockImplementation((id: string) => { } return Promise.resolve(undefined); }); -``` \ No newline at end of file +``` + +## Arrow Function Bodies + +Use expression bodies instead of block bodies when the block only contains a `return`: + +```typescript +// Good: Expression body +items.map(item => item.value * 2) +fn(arg => expression(arg, foo)) + +// Bad: Block body with just a return +items.map(item => { return item.value * 2; }) +fn(arg => { return expression(arg, foo); }) +``` + +Block bodies are appropriate when the callback has multiple statements or side effects beyond the return. \ No newline at end of file diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 3955cfc83c22..f77681a00824 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -342,19 +342,33 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra return Promise.resolve(this.synchronizer.getL1Timestamp()); } - public getSyncedL2SlotNumber(): Promise { + public async getSyncedL2SlotNumber(): Promise { + // The synced L2 slot is the latest slot for which we have all L1 data, + // either because we have seen all L1 blocks for that slot, or because + // we have seen the corresponding checkpoint. + + let slotFromL1Sync: SlotNumber | undefined; const l1Timestamp = this.synchronizer.getL1Timestamp(); - if (l1Timestamp === undefined) { - return Promise.resolve(undefined); + if (l1Timestamp !== undefined) { + const nextL1BlockSlot = getSlotAtNextL1Block(l1Timestamp, this.l1Constants); + if (Number(nextL1BlockSlot) > 0) { + slotFromL1Sync = SlotNumber.add(nextL1BlockSlot, -1); + } + } + + let slotFromCheckpoint: SlotNumber | undefined; + const latestCheckpointNumber = await this.store.getSynchedCheckpointNumber(); + if (latestCheckpointNumber > 0) { + const checkpointData = await this.store.getCheckpointData(latestCheckpointNumber); + if (checkpointData) { + slotFromCheckpoint = checkpointData.header.slotNumber; + } } - // The synced slot is the last L2 slot whose all L1 blocks have been processed. - // If the next L1 block (at l1Timestamp + ethereumSlotDuration) falls in slot N, - // then we've fully synced slot N-1. - const nextL1BlockSlot = getSlotAtNextL1Block(l1Timestamp, this.l1Constants); - if (Number(nextL1BlockSlot) === 0) { - return Promise.resolve(undefined); + + if (slotFromL1Sync === undefined && slotFromCheckpoint === undefined) { + return undefined; } - return Promise.resolve(SlotNumber(nextL1BlockSlot - 1)); + return SlotNumber(Math.max(slotFromL1Sync ?? 0, slotFromCheckpoint ?? 0)); } public async getSyncedL2EpochNumber(): Promise { diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 2a2355ff28f3..b2c3bdd99598 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -458,6 +458,14 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { }) .catch(err => log.error('Failed to start p2p services after archiver sync', err)); + const globalVariableBuilder = new GlobalVariableBuilder(dateProvider, publicClient, { + l1Contracts: config.l1Contracts, + ethereumSlotDuration: config.ethereumSlotDuration, + rollupVersion: BigInt(config.rollupVersion), + l1GenesisTime, + slotDuration: Number(slotDuration), + }); + // Validator enabled, create/start relevant service let sequencer: SequencerClient | undefined; let slasherClient: SlasherClientInterface | undefined; @@ -520,6 +528,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { dateProvider, blobClient, nodeKeyStore: keyStoreManager!, + globalVariableBuilder, }); } @@ -553,13 +562,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { } } - const globalVariableBuilder = new GlobalVariableBuilder({ - ...config, - rollupVersion: BigInt(config.rollupVersion), - l1GenesisTime, - slotDuration: Number(slotDuration), - }); - const node = new AztecNodeService( config, p2pClient, diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts new file mode 100644 index 000000000000..1fe3b1a9a47f --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts @@ -0,0 +1,152 @@ +import type { ChainMonitorEventMap } from '@aztec/ethereum/test'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { AbortError } from '@aztec/foundation/error'; +import { sleep } from '@aztec/foundation/sleep'; +import { executeTimeout } from '@aztec/foundation/timer'; +import { SequencerState } from '@aztec/sequencer-client'; +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; + +import { jest } from '@jest/globals'; + +import { EpochsTestContext } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 10); + +// Validates that the sequencer can build a block in an L2 slot even when the archiver hasn't synced +// all L1 blocks of the previous slot. This happens when an L1 slot is missed (no block produced). +// The fix relies on getSyncedL2SlotNumber using the latest synced checkpoint slot as a signal, +// bypassing the stale L1 timestamp when L1 blocks are missing. +// Regression test for https://github.com/AztecProtocol/aztec-packages/issues/14766 +describe('e2e_epochs/epochs_missed_l1_slot', () => { + let test: EpochsTestContext; + + // Use enough L1 slots per L2 slot to have room for pausing mining mid-slot. + // With 6 L1 slots per L2 slot (L1=8s, L2=48s), we have plenty of time to + // publish a checkpoint and pause mining without accidentally skipping a slot. + const L1_SLOTS_PER_L2_SLOT = 6; + + beforeEach(async () => { + test = await EpochsTestContext.setup({ + numberOfAccounts: 0, + minTxsPerBlock: 0, + aztecSlotDurationInL1Slots: L1_SLOTS_PER_L2_SLOT, + startProverNode: false, + aztecProofSubmissionEpochs: 1024, + disableAnvilTestWatcher: true, + enforceTimeTable: true, + }); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await test.teardown(); + }); + + it('builds a block after missed L1 slots when previous checkpoint is synced', async () => { + const { logger, constants, monitor, context } = test; + const eth = context.cheatCodes.eth; + const L1_BLOCK_TIME = test.L1_BLOCK_TIME_IN_S; + const L2_SLOT_DURATION = test.L2_SLOT_DURATION_IN_S; + + // Step 1: Wait for a checkpoint that's published NOT in the last L1 slot of its L2 slot. + // We need the checkpoint to land early enough that when we pause mining, the archiver's + // L1 timestamp is still in the middle of the slot (not at the end). + logger.info('Waiting for a checkpoint published early in its L2 slot...'); + const checkpointEvent = await executeTimeout( + signal => + new Promise((res, rej) => { + const handleCheckpoint = (...[ev]: ChainMonitorEventMap['checkpoint']) => { + // Skip the initial checkpoint (genesis state). + if (ev.checkpointNumber === 0) { + return; + } + const slotStart = getTimestampForSlot(ev.l2SlotNumber, constants); + const lastL1SlotStart = slotStart + BigInt(L2_SLOT_DURATION - L1_BLOCK_TIME); + if (ev.timestamp < lastL1SlotStart) { + logger.info( + `Checkpoint ${ev.checkpointNumber} in slot ${ev.l2SlotNumber} at L1 timestamp ${ev.timestamp}`, + { slotStart, lastL1SlotStart }, + ); + res(ev); + monitor.off('checkpoint', handleCheckpoint); + } else { + logger.info( + `Skipping checkpoint ${ev.checkpointNumber}: published at ${ev.timestamp} (last L1 slot starts at ${lastL1SlotStart})`, + ); + } + }; + signal.onabort = () => { + monitor.off('checkpoint', handleCheckpoint); + rej(new AbortError()); + }; + monitor.on('checkpoint', handleCheckpoint); + }), + 60_000, + 'Wait for early checkpoint', + ); + + const checkpointSlotNumber = checkpointEvent.l2SlotNumber; + const nextSlotNumber = SlotNumber(checkpointSlotNumber + 1); + const nextSlotTimestamp = Number(getTimestampForSlot(nextSlotNumber, constants)); + + logger.info(`Using checkpoint ${checkpointEvent.checkpointNumber} in L2 slot ${checkpointSlotNumber}`, { + nextSlotNumber, + nextSlotTimestamp, + }); + + // Step 2: Wait briefly for the sequencer to finish its current work cycle, then pause mining. + await sleep(1500); + + logger.info('Pausing L1 block production (simulating missed L1 slots)...'); + await eth.setAutomine(false); + await eth.setIntervalMining(0, { silent: true }); + + const frozenL1Timestamp = await eth.timestamp(); + logger.info(`L1 mining paused at L1 timestamp ${frozenL1Timestamp}`); + + // Step 3: Wait until the sequencer reaches PUBLISHING_CHECKPOINT during the mining pause. + // With the fix: the sequencer sees the checkpoint for slot N, so getSyncedL2SlotNumber + // returns N, checkSync passes for slot N+1, and it advances all the way to publishing. + // Without the fix: getSyncedL2SlotNumber is stuck at N-1, checkSync fails, sequencer + // stays in IDLE/SYNCHRONIZING and never reaches PUBLISHING_CHECKPOINT. + const sequencer = context.sequencer!.getSequencer(); + + logger.info('Waiting for sequencer to reach PUBLISHING_CHECKPOINT during mining pause...'); + await executeTimeout( + signal => + new Promise((res, rej) => { + const stateListener = ({ newState }: { newState: SequencerState }) => { + if (newState === SequencerState.PUBLISHING_CHECKPOINT) { + sequencer.off('state-changed', stateListener); + res(); + } + }; + signal.onabort = () => { + sequencer.off('state-changed', stateListener); + rej(new AbortError()); + }; + sequencer.on('state-changed', stateListener); + }), + L2_SLOT_DURATION * 2 * 1000, + 'Wait for sequencer to reach PUBLISHING_CHECKPOINT', + ); + + logger.info('Sequencer reached PUBLISHING_CHECKPOINT during mining pause'); + + // Step 4: Resume mining so the pending L1 tx lands and the test can clean up. + logger.info('Resuming L1 block production...'); + const resumeTimestamp = Math.floor(context.dateProvider.now() / 1000); + await eth.setNextBlockTimestamp(resumeTimestamp); + await eth.mine(); + await eth.setIntervalMining(L1_BLOCK_TIME); + + // Step 5: Wait for the next checkpoint to confirm the block was actually published. + const finalCheckpoint = CheckpointNumber(checkpointEvent.checkpointNumber + 1); + logger.info(`Waiting for checkpoint ${finalCheckpoint}...`); + await test.waitUntilCheckpointNumber(finalCheckpoint, 60); + await monitor.run(); + logger.info(`Checkpoint ${finalCheckpoint} published in slot ${monitor.l2SlotNumber}`); + + expect(monitor.checkpointNumber).toBeGreaterThanOrEqual(finalCheckpoint); + }); +}); diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 394f86adaa7e..2dafcdf0e69b 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -606,7 +606,7 @@ describe('L1Publisher integration', () => { const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v)); const attestations = orderAttestations(checkpointAttestations, committee!); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -630,7 +630,7 @@ describe('L1Publisher integration', () => { const attestations = orderAttestations(checkpointAttestations, committee!).reverse(); const attestationsAndSigners = new CommitteeAttestationsAndSigners(attestations); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -645,7 +645,7 @@ describe('L1Publisher integration', () => { const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v)); const attestations = orderAttestations(checkpointAttestations, committee!); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -670,7 +670,7 @@ describe('L1Publisher integration', () => { const checkpointAttestations = validators.map(v => makeCheckpointAttestationFromCheckpoint(checkpoint, v)); const attestations = orderAttestations(checkpointAttestations, committee!); - const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); + const canPropose = await publisher.canProposeAt(new Fr(GENESIS_ARCHIVE_ROOT), proposer!); expect(canPropose?.slot).toEqual(block.header.getSlot()); await publisher.validateBlockHeader(checkpoint.header); @@ -742,8 +742,8 @@ describe('L1Publisher integration', () => { // We cannot propose directly, we need to assume the previous checkpoint is invalidated const genesis = new Fr(GENESIS_ARCHIVE_ROOT); logger.warn(`Checking can propose at next eth block on top of genesis ${genesis}`); - expect(await publisher.canProposeAtNextEthBlock(genesis, proposer!)).toBeUndefined(); - const canPropose = await publisher.canProposeAtNextEthBlock(genesis, proposer!, { forcePendingCheckpointNumber }); + expect(await publisher.canProposeAt(genesis, proposer!)).toBeUndefined(); + const canPropose = await publisher.canProposeAt(genesis, proposer!, { forcePendingCheckpointNumber }); expect(canPropose?.slot).toEqual(block.header.getSlot()); // Same for validation diff --git a/yarn-project/epoch-cache/src/epoch_cache.ts b/yarn-project/epoch-cache/src/epoch_cache.ts index e961706d815c..59271c34e3a6 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.ts @@ -9,6 +9,7 @@ import { type L1RollupConstants, getEpochAtSlot, getEpochNumberAtTimestamp, + getNextL1SlotTimestamp, getSlotAtTimestamp, getSlotRangeForEpoch, getTimestampForSlot, @@ -148,10 +149,6 @@ export class EpochCache implements EpochCacheInterface { return { ...this.getEpochAndSlotAtTimestamp(nowSeconds), nowMs }; } - public nowInSeconds(): bigint { - return BigInt(Math.floor(this.dateProvider.now() / 1000)); - } - private getEpochAndSlotAtSlot(slot: SlotNumber): EpochAndSlot { const epoch = getEpochAtSlot(slot, this.l1constants); const ts = getTimestampRangeForEpoch(epoch, this.l1constants)[0]; @@ -159,8 +156,8 @@ export class EpochCache implements EpochCacheInterface { } public getEpochAndSlotInNextL1Slot(): EpochAndSlot & { now: bigint } { - const now = this.nowInSeconds(); - const nextSlotTs = now + BigInt(this.l1constants.ethereumSlotDuration); + const now = BigInt(this.dateProvider.nowInSeconds()); + const nextSlotTs = getNextL1SlotTimestamp(Number(now), this.l1constants); return { ...this.getEpochAndSlotAtTimestamp(nextSlotTs), now }; } @@ -376,10 +373,11 @@ export class EpochCache implements EpochCacheInterface { async getRegisteredValidators(): Promise { const validatorRefreshIntervalMs = this.config.validatorRefreshIntervalSeconds * 1000; const validatorRefreshTime = this.lastValidatorRefresh + validatorRefreshIntervalMs; - if (validatorRefreshTime < this.dateProvider.now()) { - const currentSet = await this.rollup.getAttesters(); + const now = this.dateProvider.now(); + if (validatorRefreshTime < now) { + const currentSet = await this.rollup.getAttesters(BigInt(Math.floor(now / 1000))); this.allValidators = new Set(currentSet.map(v => v.toString())); - this.lastValidatorRefresh = this.dateProvider.now(); + this.lastValidatorRefresh = now; } return Array.from(this.allValidators.keys()).map(v => EthAddress.fromString(v)); } diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 980ff98ed12c..538b04d6aa82 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -777,14 +777,13 @@ export class RollupContract { * timestamp of the next L1 block * @throws otherwise */ - public async canProposeAtNextEthBlock( + public async canProposeAt( archive: Buffer, account: `0x${string}` | Account, - slotDuration: number, + timestamp: bigint, opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {}, ): Promise<{ slot: SlotNumber; checkpointNumber: CheckpointNumber; timeOfNextL1Slot: bigint }> { - const latestBlock = await this.client.getBlock(); - const timeOfNextL1Slot = latestBlock.timestamp + BigInt(slotDuration); + const timeOfNextL1Slot = timestamp; const who = typeof account === 'string' ? account : account.address; try { @@ -937,11 +936,10 @@ export class RollupContract { return this.rollup.read.getSpecificProverRewardsForEpoch([epoch, prover]); } - async getAttesters(): Promise { + async getAttesters(timestamp?: bigint): Promise { const attesterSize = await this.getActiveAttesterCount(); const gse = new GSEContract(this.client, await this.getGSE()); - const ts = (await this.client.getBlock()).timestamp; - + const ts = timestamp ?? (await this.client.getBlock()).timestamp; const indices = Array.from({ length: attesterSize }, (_, i) => BigInt(i)); const chunks = chunk(indices, 1000); diff --git a/yarn-project/foundation/src/branded-types/slot.ts b/yarn-project/foundation/src/branded-types/slot.ts index 069104a88fe0..2657cd622036 100644 --- a/yarn-project/foundation/src/branded-types/slot.ts +++ b/yarn-project/foundation/src/branded-types/slot.ts @@ -73,6 +73,11 @@ SlotNumber.isValid = function (value: unknown): value is SlotNumber { return typeof value === 'number' && Number.isInteger(value) && value >= 0; }; +/** Increments a SlotNumber by a given value. */ +SlotNumber.add = function (sn: SlotNumber, increment: number): SlotNumber { + return SlotNumber(sn + increment); +}; + /** * The zero slot value. */ diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index 6c00c41db92c..6afbb525df8a 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -20,7 +20,7 @@ import { L1Metrics, type TelemetryClient } from '@aztec/telemetry-client'; import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client'; import { type SequencerClientConfig, getPublisherConfigFromSequencerConfig } from '../config.js'; -import { GlobalVariableBuilder } from '../global_variable_builder/index.js'; +import type { GlobalVariableBuilder } from '../global_variable_builder/index.js'; import { SequencerPublisherFactory } from '../publisher/sequencer-publisher-factory.js'; import { Sequencer, type SequencerConfig } from '../sequencer/index.js'; @@ -66,6 +66,7 @@ export class SequencerClient { epochCache?: EpochCache; l1TxUtils: L1TxUtils[]; nodeKeyStore: KeystoreManager; + globalVariableBuilder: GlobalVariableBuilder; }, ) { const { @@ -93,10 +94,9 @@ export class SequencerClient { log.getBindings(), ); const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); - const [l1GenesisTime, slotDuration, rollupVersion, rollupManaLimit] = await Promise.all([ + const [l1GenesisTime, slotDuration, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), rollupContract.getSlotDuration(), - rollupContract.getVersion(), rollupContract.getManaLimit().then(Number), ] as const); @@ -139,13 +139,7 @@ export class SequencerClient { const ethereumSlotDuration = config.ethereumSlotDuration; - const globalsBuilder = new GlobalVariableBuilder({ - ...config, - l1GenesisTime, - slotDuration: Number(slotDuration), - ethereumSlotDuration, - rollupVersion, - }); + const globalsBuilder = deps.globalVariableBuilder; // When running in anvil, assume we can post a tx up until one second before the end of an L1 slot. // Otherwise, we need the full L1 slot duration for publishing to ensure inclusion. diff --git a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts index 20c0f236292d..3043f9a56eed 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts @@ -1,15 +1,13 @@ -import { createEthereumChain } from '@aztec/ethereum/chain'; -import { makeL1HttpTransport } from '@aztec/ethereum/client'; -import type { L1ContractsConfig } from '@aztec/ethereum/config'; import { RollupContract } from '@aztec/ethereum/contracts'; -import type { L1ReaderConfig } from '@aztec/ethereum/l1-reader'; +import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ViemPublicClient } from '@aztec/ethereum/types'; import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; +import type { DateProvider } from '@aztec/foundation/timer'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { type L1RollupConstants, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import { type L1RollupConstants, getNextL1SlotTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; import type { CheckpointGlobalVariables, @@ -17,7 +15,12 @@ import type { } from '@aztec/stdlib/tx'; import { GlobalVariables } from '@aztec/stdlib/tx'; -import { createPublicClient } from 'viem'; +/** Configuration for the GlobalVariableBuilder (excludes L1 client config). */ +export type GlobalVariableBuilderConfig = { + l1Contracts: Pick; + ethereumSlotDuration: number; + rollupVersion: bigint; +} & Pick; /** * Simple global variables builder. @@ -28,7 +31,6 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { private currentL1BlockNumber: bigint | undefined = undefined; private readonly rollupContract: RollupContract; - private readonly publicClient: ViemPublicClient; private readonly ethereumSlotDuration: number; private readonly aztecSlotDuration: number; private readonly l1GenesisTime: bigint; @@ -37,28 +39,18 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { private version: Fr; constructor( - config: L1ReaderConfig & - Pick & - Pick & { rollupVersion: bigint }, + private readonly dateProvider: DateProvider, + private readonly publicClient: ViemPublicClient, + config: GlobalVariableBuilderConfig, ) { - const { l1RpcUrls, l1ChainId: chainId, l1Contracts } = config; - - const chain = createEthereumChain(l1RpcUrls, chainId); - this.version = new Fr(config.rollupVersion); - this.chainId = new Fr(chainId); + this.chainId = new Fr(this.publicClient.chain!.id); this.ethereumSlotDuration = config.ethereumSlotDuration; this.aztecSlotDuration = config.slotDuration; this.l1GenesisTime = config.l1GenesisTime; - this.publicClient = createPublicClient({ - chain: chain.chainInfo, - transport: makeL1HttpTransport(chain.rpcUrls, { timeout: config.l1HttpTimeoutMS }), - pollingInterval: config.viemPollingIntervalMS, - }); - - this.rollupContract = new RollupContract(this.publicClient, l1Contracts.rollupAddress); + this.rollupContract = new RollupContract(this.publicClient, config.l1Contracts.rollupAddress); } /** @@ -74,7 +66,10 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { const earliestTimestamp = await this.rollupContract.getTimestampForSlot( SlotNumber.fromBigInt(BigInt(lastCheckpoint.slotNumber) + 1n), ); - const nextEthTimestamp = BigInt((await this.publicClient.getBlock()).timestamp + BigInt(this.ethereumSlotDuration)); + const nextEthTimestamp = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), { + l1GenesisTime: this.l1GenesisTime, + ethereumSlotDuration: this.ethereumSlotDuration, + }); const timestamp = earliestTimestamp > nextEthTimestamp ? earliestTimestamp : nextEthTimestamp; return new GasFees(0, await this.rollupContract.getManaMinFeeAt(timestamp, true)); @@ -109,7 +104,10 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { const slot: SlotNumber = maybeSlot ?? (await this.rollupContract.getSlotAt( - BigInt((await this.publicClient.getBlock()).timestamp + BigInt(this.ethereumSlotDuration)), + getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), { + l1GenesisTime: this.l1GenesisTime, + ethereumSlotDuration: this.ethereumSlotDuration, + }), )); const checkpointGlobalVariables = await this.buildCheckpointGlobalVariables(coinbase, feeRecipient, slot); diff --git a/yarn-project/sequencer-client/src/global_variable_builder/index.ts b/yarn-project/sequencer-client/src/global_variable_builder/index.ts index 5669a0412ae4..a48ed6c244eb 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/index.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/index.ts @@ -1 +1 @@ -export { GlobalVariableBuilder } from './global_builder.js'; +export { GlobalVariableBuilder, type GlobalVariableBuilderConfig } from './global_builder.js'; diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index bf26a3ae16e1..12592d5a1042 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -22,6 +22,7 @@ import { TestDateProvider } from '@aztec/foundation/timer'; import { EmpireBaseAbi, RollupAbi } from '@aztec/l1-artifacts'; import { CommitteeAttestationsAndSigners, L2Block, Signature } from '@aztec/stdlib/block'; import { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import type { SlashFactoryContract } from '@aztec/stdlib/l1-contracts'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; @@ -138,6 +139,7 @@ describe('SequencerPublisher', () => { const epochCache = mock(); epochCache.getEpochAndSlotNow.mockReturnValue({ epoch: EpochNumber(1), slot: SlotNumber(2), ts: 3n, nowMs: 3000n }); + epochCache.getL1Constants.mockReturnValue(EmptyL1RollupConstants); epochCache.getCommittee.mockResolvedValue({ committee: [], seed: 1n, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index ff1616869741..3ff0e0d87893 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -41,6 +41,7 @@ import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts'; import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher'; import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block'; import type { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers'; import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts'; import type { CheckpointHeader } from '@aztec/stdlib/rollup'; import type { L1PublishCheckpointStats } from '@aztec/stdlib/stats'; @@ -121,6 +122,7 @@ export class SequencerPublisher { protected log: Logger; protected ethereumSlotDuration: bigint; + private dateProvider: DateProvider; private blobClient: BlobClientInterface; @@ -169,6 +171,7 @@ export class SequencerPublisher { ) { this.log = deps.log ?? createLogger('sequencer:publisher'); this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration); + this.dateProvider = deps.dateProvider; this.epochCache = deps.epochCache; this.lastActions = deps.lastActions; @@ -450,11 +453,11 @@ export class SequencerPublisher { } /** - * @notice Will call `canProposeAtNextEthBlock` to make sure that it is possible to propose + * @notice Will call `canProposeAt` to make sure that it is possible to propose * @param tipArchive - The archive to check * @returns The slot and block number if it is possible to propose, undefined otherwise */ - public canProposeAtNextEthBlock( + public async canProposeAt( tipArchive: Fr, msgSender: EthAddress, opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {}, @@ -462,8 +465,10 @@ export class SequencerPublisher { // TODO: #14291 - should loop through multiple keys to check if any of them can propose const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive']; + const nextL1SlotTs = await this.getNextL1SlotTimestampWithL1Floor(); + return this.rollupContract - .canProposeAtNextEthBlock(tipArchive.toBuffer(), msgSender.toString(), Number(this.ethereumSlotDuration), { + .canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, { forcePendingCheckpointNumber: opts.forcePendingCheckpointNumber, }) .catch(err => { @@ -500,7 +505,7 @@ export class SequencerPublisher { flags, ] as const; - const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration); + const ts = await this.getNextL1SlotTimestampWithL1Floor(); const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride( opts?.forcePendingCheckpointNumber, ); @@ -1355,4 +1360,21 @@ export class SequencerPublisher { }, }); } + + /** + * Returns the timestamp to use when simulating L1 proposal calls. + * Uses the wall-clock-based next L1 slot boundary, but floors it with the latest L1 block timestamp + * plus one slot duration. This prevents the sequencer from targeting a future L2 slot when the L1 + * chain hasn't caught up to the wall clock yet (e.g., the dateProvider is one L1 slot ahead of the + * latest mined block), which would cause the propose tx to land in an L1 block with block.timestamp + * still in the previous L2 slot. + * TODO(palla): Properly fix by keeping dateProvider synced with anvil's chain time on every block. + */ + private async getNextL1SlotTimestampWithL1Floor(): Promise { + const l1Constants = this.epochCache.getL1Constants(); + const fromWallClock = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants); + const latestBlock = await this.l1TxUtils.client.getBlock(); + const fromL1Block = latestBlock.timestamp + BigInt(l1Constants.ethereumSlotDuration); + return fromWallClock > fromL1Block ? fromWallClock : fromL1Block; + } } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index c30a93fb770e..d6d6fcc80dce 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -187,7 +187,7 @@ describe('sequencer', () => { publisher.enqueueProposeCheckpoint.mockResolvedValue(undefined); publisher.enqueueGovernanceCastSignal.mockResolvedValue(true); publisher.enqueueSlashingActions.mockResolvedValue(true); - publisher.canProposeAtNextEthBlock.mockResolvedValue({ + publisher.canProposeAt.mockResolvedValue({ slot: SlotNumber(newSlotNumber), checkpointNumber: CheckpointNumber.fromBlockNumber(newBlockNumber), timeOfNextL1Slot: 1000n, @@ -352,21 +352,21 @@ describe('sequencer', () => { expect(checkpointBuilder.buildBlockCalls).toHaveLength(0); expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled(); - expect(publisher.canProposeAtNextEthBlock).not.toHaveBeenCalled(); + expect(publisher.canProposeAt).not.toHaveBeenCalled(); }); it('builds a checkpoint when it is their turn', async () => { await setupSingleTxBlock(); - // Not your turn! canProposeAtNextEthBlock returns undefined - publisher.canProposeAtNextEthBlock.mockResolvedValue(undefined); + // Not your turn! canProposeAt returns undefined + publisher.canProposeAt.mockResolvedValue(undefined); await sequencer.work(); // When it's not our turn, we should not build the checkpoint expect(checkpointBuilder.buildBlockCalls).toHaveLength(0); // Now it's our turn! - publisher.canProposeAtNextEthBlock.mockResolvedValue({ + publisher.canProposeAt.mockResolvedValue({ slot: block.header.globalVariables.slotNumber, checkpointNumber: CheckpointNumber.fromBlockNumber(block.header.globalVariables.blockNumber), timeOfNextL1Slot: 1000n, @@ -474,7 +474,7 @@ describe('sequencer', () => { pub.enqueueProposeCheckpoint.mockResolvedValue(undefined); pub.enqueueGovernanceCastSignal.mockResolvedValue(true); pub.enqueueSlashingActions.mockResolvedValue(true); - pub.canProposeAtNextEthBlock.mockResolvedValue({ + pub.canProposeAt.mockResolvedValue({ slot: SlotNumber(newSlotNumber + i), checkpointNumber: CheckpointNumber.fromBlockNumber(BlockNumber(newBlockNumber)), timeOfNextL1Slot: 1000n, diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index d75788ea3cf4..3af12aec8200 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -327,7 +327,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter { // Check that the archiver has fully synced the L2 slot before the one we want to propose in. - // TODO(#14766): Archiver reports L1 timestamp based on L1 blocks seen, which means that a missed L1 block will - // cause the archiver L1 timestamp to fall behind, and cause this sequencer to start processing one L1 slot later. + // The archiver reports sync progress via L1 block timestamps and synced checkpoint slots. + // See getSyncedL2SlotNumber for how missed L1 blocks are handled. const syncedL2Slot = await this.l2BlockSource.getSyncedL2SlotNumber(); const { slot } = args; if (syncedL2Slot === undefined || syncedL2Slot + 1 < slot) { diff --git a/yarn-project/stdlib/src/block/l2_block_source.ts b/yarn-project/stdlib/src/block/l2_block_source.ts index 75f181a003f2..af020893af9c 100644 --- a/yarn-project/stdlib/src/block/l2_block_source.ts +++ b/yarn-project/stdlib/src/block/l2_block_source.ts @@ -176,8 +176,10 @@ export interface L2BlockSource { getSettledTxReceipt(txHash: TxHash): Promise; /** - * Returns the last L2 slot number that has been fully synchronized from L1. - * An L2 slot is fully synced when all L1 blocks that fall within its time range have been processed. + * Returns the last L2 slot number for which we have all L1 data needed to build the next checkpoint. + * Determined by the max of two signals: L1 block sync progress and latest synced checkpoint slot. + * The checkpoint signal handles missed L1 blocks, since a published checkpoint seals the message tree + * for the next checkpoint via the inbox LAG mechanism. */ getSyncedL2SlotNumber(): Promise; diff --git a/yarn-project/stdlib/src/epoch-helpers/index.ts b/yarn-project/stdlib/src/epoch-helpers/index.ts index 637afa3caf09..0ae35f5461f4 100644 --- a/yarn-project/stdlib/src/epoch-helpers/index.ts +++ b/yarn-project/stdlib/src/epoch-helpers/index.ts @@ -57,6 +57,17 @@ export function getSlotAtTimestamp( : SlotNumber.fromBigInt((ts - constants.l1GenesisTime) / BigInt(constants.slotDuration)); } +/** Returns the timestamp of the next L1 slot boundary after the given wall-clock time. */ +export function getNextL1SlotTimestamp( + nowInSeconds: number, + constants: Pick, +): bigint { + const now = BigInt(nowInSeconds); + const elapsed = now - constants.l1GenesisTime; + const currentL1Slot = elapsed / BigInt(constants.ethereumSlotDuration); + return constants.l1GenesisTime + (currentL1Slot + 1n) * BigInt(constants.ethereumSlotDuration); +} + /** Returns the L2 slot number at the next L1 block based on the current timestamp. */ export function getSlotAtNextL1Block( currentL1Timestamp: bigint, From 5402b0af20540af75eae0626c88054cf6a290ba3 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:11:29 -0400 Subject: [PATCH 09/10] chore: backport PR #21788 (feat(p2p): add tx validation for contract class id verification) to v4-next (#21852) ## Summary Backport of https://github.com/AztecProtocol/aztec-packages/pull/21788 to v4-next. Adds contract class ID validation to the `DataTxValidator` and archiver's `updatePublishedContractClasses`, preventing malicious txs from registering classes with mismatched IDs. Also simplifies `toContractClassPublic()` to be synchronous with explicit validation at call sites. ## Conflicts Resolved Cherry-pick had 7 conflict regions across 5 files. Key differences on v4-next: - `ContractClassPublic` type includes `privateFunctions`/`utilityFunctions` arrays (not present on next) - Different import paths for `DataStoreConfig` and `L1RollupConstants` - Different test expectations for duplicate contract class registration Full conflict resolution analysis: https://gist.github.com/AztecBot/7bae9da9cdc612e1df373324a58e6b24 ## Test plan - TypeScript compilation passes for all modified packages - Existing tests should pass (test structure adapted for v4-next) - CI will validate end-to-end" ClaudeBox log: https://claudebox.work/s/4066b8ad44d62582?run=1 --------- Co-authored-by: Santiago Palladino --- yarn-project/archiver/src/factory.ts | 9 +- .../src/modules/data_store_updater.ts | 42 ++++-- .../src/store/kv_archiver_store.test.ts | 37 +++--- .../archiver/src/store/kv_archiver_store.ts | 12 +- .../tx_validator/data_validator.test.ts | 123 +++++++++++++++++- .../tx_validator/data_validator.ts | 43 +++++- .../tx_validator/phases_validator.ts | 2 +- .../contract_class_published_event.ts | 29 ++--- .../simulator/src/public/public_db_sources.ts | 24 ++-- .../public_processor/public_processor.test.ts | 4 +- .../public_processor/public_processor.ts | 2 +- .../contract_provider_for_cpp.ts | 3 +- .../public_tx_simulator.ts | 4 +- .../src/contract/contract_class_id.test.ts | 19 +++ .../stdlib/src/tx/validator/error_texts.ts | 8 ++ 15 files changed, 279 insertions(+), 82 deletions(-) diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index f056d687296f..fd4f10b6549d 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -14,7 +14,7 @@ import { protocolContractNames } from '@aztec/protocol-contracts'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; import { FunctionType, decodeFunctionSignature } from '@aztec/stdlib/abi'; import type { ArchiverEmitter } from '@aztec/stdlib/block'; -import { type ContractClassPublic, computePublicBytecodeCommitment } from '@aztec/stdlib/contract'; +import { type ContractClassPublicWithCommitment, computePublicBytecodeCommitment } from '@aztec/stdlib/contract'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { getTelemetryClient } from '@aztec/telemetry-client'; @@ -187,8 +187,10 @@ export async function registerProtocolContracts(store: KVArchiverDataStore) { continue; } - const contractClassPublic: ContractClassPublic = { + const publicBytecodeCommitment = await computePublicBytecodeCommitment(contract.contractClass.packedBytecode); + const contractClassPublic: ContractClassPublicWithCommitment = { ...contract.contractClass, + publicBytecodeCommitment, privateFunctions: [], utilityFunctions: [], }; @@ -198,8 +200,7 @@ export async function registerProtocolContracts(store: KVArchiverDataStore) { .map(fn => decodeFunctionSignature(fn.name, fn.parameters)); await store.registerContractFunctionSignatures(publicFunctionSignatures); - const bytecodeCommitment = await computePublicBytecodeCommitment(contractClassPublic.packedBytecode); - await store.addContractClasses([contractClassPublic], [bytecodeCommitment], BlockNumber(blockNumber)); + await store.addContractClasses([contractClassPublic], BlockNumber(blockNumber)); await store.addContractInstances([contract.instance], BlockNumber(blockNumber)); } } diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index 6bf2975b3c3f..a490a80e84f9 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -13,9 +13,10 @@ import { import type { L2Block, ValidateCheckpointResult } from '@aztec/stdlib/block'; import { type PublishedCheckpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { + type ContractClassPublicWithCommitment, type ExecutablePrivateFunctionWithMembershipProof, type UtilityFunctionWithMembershipProof, - computePublicBytecodeCommitment, + computeContractClassId, isValidPrivateFunctionMembershipProof, isValidUtilityFunctionMembershipProof, } from '@aztec/stdlib/contract'; @@ -321,18 +322,37 @@ export class ArchiverDataStoreUpdater { .filter(log => ContractClassPublishedEvent.isContractClassPublishedEvent(log)) .map(log => ContractClassPublishedEvent.fromLog(log)); - const contractClasses = await Promise.all(contractClassPublishedEvents.map(e => e.toContractClassPublic())); - if (contractClasses.length > 0) { - contractClasses.forEach(c => this.log.verbose(`${Operation[operation]} contract class ${c.id.toString()}`)); - if (operation == Operation.Store) { - // TODO: Will probably want to create some worker threads to compute these bytecode commitments as they are expensive - const commitments = await Promise.all( - contractClasses.map(c => computePublicBytecodeCommitment(c.packedBytecode)), - ); - return await this.store.addContractClasses(contractClasses, commitments, blockNum); - } else if (operation == Operation.Delete) { + if (operation == Operation.Delete) { + const contractClasses = contractClassPublishedEvents.map(e => e.toContractClassPublic()); + if (contractClasses.length > 0) { + contractClasses.forEach(c => this.log.verbose(`${Operation[operation]} contract class ${c.id.toString()}`)); return await this.store.deleteContractClasses(contractClasses, blockNum); } + return true; + } + + // Compute bytecode commitments and validate class IDs in a single pass. + const contractClasses: ContractClassPublicWithCommitment[] = []; + for (const event of contractClassPublishedEvents) { + const contractClass = await event.toContractClassPublicWithBytecodeCommitment(); + const computedClassId = await computeContractClassId({ + artifactHash: contractClass.artifactHash, + privateFunctionsRoot: contractClass.privateFunctionsRoot, + publicBytecodeCommitment: contractClass.publicBytecodeCommitment, + }); + if (!computedClassId.equals(contractClass.id)) { + this.log.warn( + `Skipping contract class with mismatched id at block ${blockNum}. Claimed ${contractClass.id}, computed ${computedClassId}`, + { blockNum, contractClassId: event.contractClassId.toString() }, + ); + continue; + } + contractClasses.push(contractClass); + } + + if (contractClasses.length > 0) { + contractClasses.forEach(c => this.log.verbose(`${Operation[operation]} contract class ${c.id.toString()}`)); + return await this.store.addContractClasses(contractClasses, blockNum); } return true; } diff --git a/yarn-project/archiver/src/store/kv_archiver_store.test.ts b/yarn-project/archiver/src/store/kv_archiver_store.test.ts index 5b7c188ccf78..ca9558c67c73 100644 --- a/yarn-project/archiver/src/store/kv_archiver_store.test.ts +++ b/yarn-project/archiver/src/store/kv_archiver_store.test.ts @@ -25,6 +25,7 @@ import { import { Checkpoint, PublishedCheckpoint, randomCheckpointInfo } from '@aztec/stdlib/checkpoint'; import { type ContractClassPublic, + type ContractClassPublicWithCommitment, type ContractInstanceWithAddress, SerializableContractInstance, computePublicBytecodeCommitment, @@ -78,6 +79,13 @@ async function addProposedBlocks( return result; } +async function withCommitment(contractClass: ContractClassPublic): Promise { + return { + ...contractClass, + publicBytecodeCommitment: await computePublicBytecodeCommitment(contractClass.packedBytecode), + }; +} + describe('KVArchiverDataStore', () => { let store: KVArchiverDataStore; let publishedCheckpoints: PublishedCheckpoint[]; @@ -2185,11 +2193,7 @@ describe('KVArchiverDataStore', () => { beforeEach(async () => { contractClass = await makeContractClassPublic(); - await store.addContractClasses( - [contractClass], - [await computePublicBytecodeCommitment(contractClass.packedBytecode)], - BlockNumber(blockNum), - ); + await store.addContractClasses([await withCommitment(contractClass)], BlockNumber(blockNum)); }); it('returns previously stored contract class', async () => { @@ -2203,14 +2207,15 @@ describe('KVArchiverDataStore', () => { it('throws if the same contract class is added again', async () => { await expect( - store.addContractClasses( - [contractClass], - [await computePublicBytecodeCommitment(contractClass.packedBytecode)], - BlockNumber(blockNum + 1), - ), + store.addContractClasses([await withCommitment(contractClass)], BlockNumber(blockNum + 1)), ).rejects.toThrow(/already exists/); }); + it('returns contract class if deleted at a later block number', async () => { + await store.deleteContractClasses([contractClass], BlockNumber(blockNum + 1)); + await expect(store.getContractClass(contractClass.id)).resolves.toMatchObject(contractClass); + }); + it('returns undefined if contract class is not found', async () => { await expect(store.getContractClass(Fr.random())).resolves.toBeUndefined(); }); @@ -3091,21 +3096,17 @@ describe('KVArchiverDataStore', () => { it('throws when adding the same contract class twice', async () => { const contractClass = await makeContractClassPublic(); - const commitment = await computePublicBytecodeCommitment(contractClass.packedBytecode); + const contractClassWithCommitment = await withCommitment(contractClass); - await store.addContractClasses([contractClass], [commitment], BlockNumber(1)); - await expect(store.addContractClasses([contractClass], [commitment], BlockNumber(2))).rejects.toThrow( + await store.addContractClasses([contractClassWithCommitment], BlockNumber(1)); + await expect(store.addContractClasses([contractClassWithCommitment], BlockNumber(2))).rejects.toThrow( /already exists/, ); }); it('throws when adding the same contract instance twice', async () => { const contractClass = await makeContractClassPublic(); - await store.addContractClasses( - [contractClass], - [await computePublicBytecodeCommitment(contractClass.packedBytecode)], - BlockNumber(1), - ); + await store.addContractClasses([await withCommitment(contractClass)], BlockNumber(1)); const instance = { ...(await SerializableContractInstance.random({ diff --git a/yarn-project/archiver/src/store/kv_archiver_store.ts b/yarn-project/archiver/src/store/kv_archiver_store.ts index 7db1342c198b..3279db17ef63 100644 --- a/yarn-project/archiver/src/store/kv_archiver_store.ts +++ b/yarn-project/archiver/src/store/kv_archiver_store.ts @@ -16,6 +16,7 @@ import { import type { CheckpointData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { ContractClassPublic, + ContractClassPublicWithCommitment, ContractDataSource, ContractInstanceUpdateWithAddress, ContractInstanceWithAddress, @@ -168,19 +169,14 @@ export class KVArchiverDataStore implements ContractDataSource { /** * Add new contract classes from an L2 block to the store's list. - * @param data - List of contract classes to be added. - * @param bytecodeCommitments - Bytecode commitments for the contract classes. + * @param data - List of contract classes (with bytecode commitments) to be added. * @param blockNumber - Number of the L2 block the contracts were registered in. * @returns True if the operation is successful. */ - async addContractClasses( - data: ContractClassPublic[], - bytecodeCommitments: Fr[], - blockNumber: BlockNumber, - ): Promise { + async addContractClasses(data: ContractClassPublicWithCommitment[], blockNumber: BlockNumber): Promise { return ( await Promise.all( - data.map((c, i) => this.#contractClassStore.addContractClass(c, bytecodeCommitments[i], blockNumber)), + data.map(c => this.#contractClassStore.addContractClass(c, c.publicBytecodeCommitment, blockNumber)), ) ).every(Boolean); } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.test.ts index 2820c3a108b6..3f3c4ae17d00 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.test.ts @@ -1,14 +1,18 @@ import { CONTRACT_CLASS_LOG_SIZE_IN_FIELDS, + CONTRACT_CLASS_PUBLISHED_MAGIC_VALUE, MAX_CONTRACT_CLASS_LOGS_PER_TX, MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS, } from '@aztec/constants'; import { timesParallel } from '@aztec/foundation/collection'; import { randomInt } from '@aztec/foundation/crypto/random'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { ProtocolContractAddress } from '@aztec/protocol-contracts'; +import { bufferAsFields } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { computeContractClassId, computePublicBytecodeCommitment } from '@aztec/stdlib/contract'; import { LogHash, ScopedLogHash } from '@aztec/stdlib/kernel'; -import { ContractClassLogFields } from '@aztec/stdlib/logs'; +import { ContractClassLog, ContractClassLogFields } from '@aztec/stdlib/logs'; import { mockTx } from '@aztec/stdlib/testing'; import { TX_ERROR_CALLDATA_COUNT_MISMATCH, @@ -17,6 +21,8 @@ import { TX_ERROR_CONTRACT_CLASS_LOG_COUNT, TX_ERROR_CONTRACT_CLASS_LOG_LENGTH, TX_ERROR_INCORRECT_CALLDATA, + TX_ERROR_INCORRECT_CONTRACT_CLASS_ID, + TX_ERROR_MALFORMED_CONTRACT_CLASS_LOG, type Tx, } from '@aztec/stdlib/tx'; @@ -243,4 +249,119 @@ describe('TxDataValidator', () => { await expectInvalid(badTxs[0], TX_ERROR_CONTRACT_CLASS_LOG_LENGTH); }); + + describe('contract class id validation', () => { + /** + * Builds a ContractClassLog encoding a ContractClassPublishedEvent. + * Layout: [magic, contractClassId, version, artifactHash, privateFunctionsRoot, ...bytecodeAsFields] + */ + async function buildContractClassLog(opts?: { contractClassId?: Fr }): Promise<{ + log: ContractClassLog; + emittedLength: number; + }> { + const artifactHash = Fr.random(); + const privateFunctionsRoot = Fr.random(); + const packedBytecode = Buffer.from('aabbccdd', 'hex'); + + const bytecodeCommitment = await computePublicBytecodeCommitment(packedBytecode); + const correctClassId = await computeContractClassId({ + artifactHash, + privateFunctionsRoot, + publicBytecodeCommitment: bytecodeCommitment, + }); + const contractClassId = opts?.contractClassId ?? correctClassId; + + const bytecodeFields = bufferAsFields(packedBytecode, CONTRACT_CLASS_LOG_SIZE_IN_FIELDS); + let lastNonZero = bytecodeFields.length - 1; + while (lastNonZero >= 0 && bytecodeFields[lastNonZero].isZero()) { + lastNonZero--; + } + const bytecodeEmittedFields = bytecodeFields.slice(0, lastNonZero + 1); + + const headerFields = [ + new Fr(CONTRACT_CLASS_PUBLISHED_MAGIC_VALUE), + contractClassId, + new Fr(1), // version + artifactHash, + privateFunctionsRoot, + ]; + + const emittedFields = [...headerFields, ...bytecodeEmittedFields]; + const emittedLength = emittedFields.length; + + const allFields = [ + ...emittedFields, + ...Array(CONTRACT_CLASS_LOG_SIZE_IN_FIELDS - emittedFields.length).fill(Fr.ZERO), + ]; + + const fields = new ContractClassLogFields(allFields); + const log = new ContractClassLog(ProtocolContractAddress.ContractClassRegistry, fields, emittedLength); + return { log, emittedLength }; + } + + async function injectContractClassLog(tx: Tx, log: ContractClassLog, emittedLength: number) { + tx.contractClassLogFields.push(log.fields); + const logHashes = tx.data.forPublic!.nonRevertibleAccumulatedData.contractClassLogsHashes; + const emptyIdx = logHashes.findIndex(h => h.isEmpty()); + if (emptyIdx >= 0) { + logHashes[emptyIdx] = LogHash.from({ + value: await log.fields.hash(), + length: emittedLength, + }).scope(log.contractAddress); + } + } + + it('allows transactions with correct contract class ids', async () => { + const tx = await mockTx(2, { + numberOfNonRevertiblePublicCallRequests: 1, + numberOfRevertiblePublicCallRequests: 0, + }); + const { log, emittedLength } = await buildContractClassLog(); + await injectContractClassLog(tx, log, emittedLength); + await tx.recomputeHash(); + await expect(validator.validateTx(tx)).resolves.toEqual({ result: 'valid' }); + }); + + it('rejects transactions with incorrect contract class ids', async () => { + const tx = await mockTx(3, { + numberOfNonRevertiblePublicCallRequests: 1, + numberOfRevertiblePublicCallRequests: 0, + }); + const { log, emittedLength } = await buildContractClassLog({ contractClassId: Fr.random() }); + await injectContractClassLog(tx, log, emittedLength); + await tx.recomputeHash(); + await expect(validator.validateTx(tx)).resolves.toEqual({ + result: 'invalid', + reason: [TX_ERROR_INCORRECT_CONTRACT_CLASS_ID], + }); + }); + + it('rejects transactions with malformed contract class logs', async () => { + const tx = await mockTx(4, { + numberOfNonRevertiblePublicCallRequests: 1, + numberOfRevertiblePublicCallRequests: 0, + }); + const headerFields = [ + new Fr(CONTRACT_CLASS_PUBLISHED_MAGIC_VALUE), + Fr.random(), + new Fr(1), + Fr.random(), + Fr.random(), + new Fr(999999), // bogus bytecode length + ]; + const allFields = [ + ...headerFields, + ...Array(CONTRACT_CLASS_LOG_SIZE_IN_FIELDS - headerFields.length).fill(Fr.ZERO), + ]; + const fields = new ContractClassLogFields(allFields); + const log = new ContractClassLog(ProtocolContractAddress.ContractClassRegistry, fields, headerFields.length); + await injectContractClassLog(tx, log, headerFields.length); + await tx.recomputeHash(); + const result = await validator.validateTx(tx); + expect(result.result).toBe('invalid'); + expect(result.result === 'invalid' && result.reason[0]).toMatch( + new RegExp(`${TX_ERROR_INCORRECT_CONTRACT_CLASS_ID}|${TX_ERROR_MALFORMED_CONTRACT_CLASS_LOG}`), + ); + }); + }); }); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.ts index 692e4705e186..7c284b6d0ce3 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/data_validator.ts @@ -1,5 +1,7 @@ import { MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS } from '@aztec/constants'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; +import { ContractClassPublishedEvent } from '@aztec/protocol-contracts/class-registry'; +import { computeContractClassId } from '@aztec/stdlib/contract'; import { computeCalldataHash } from '@aztec/stdlib/hash'; import { TX_ERROR_CALLDATA_COUNT_MISMATCH, @@ -9,7 +11,9 @@ import { TX_ERROR_CONTRACT_CLASS_LOG_LENGTH, TX_ERROR_CONTRACT_CLASS_LOG_SORTING, TX_ERROR_INCORRECT_CALLDATA, + TX_ERROR_INCORRECT_CONTRACT_CLASS_ID, TX_ERROR_INCORRECT_HASH, + TX_ERROR_MALFORMED_CONTRACT_CLASS_LOG, Tx, type TxValidationResult, type TxValidator, @@ -26,7 +30,8 @@ export class DataTxValidator implements TxValidator { const reason = (await this.#hasCorrectHash(tx)) ?? (await this.#hasCorrectCalldata(tx)) ?? - (await this.#hasCorrectContractClassLogs(tx)); + (await this.#hasCorrectContractClassLogs(tx)) ?? + (await this.#hasCorrectContractClassIds(tx)); return reason ? { result: 'invalid', reason: [reason] } : { result: 'valid' }; } @@ -127,4 +132,40 @@ export class DataTxValidator implements TxValidator { return undefined; } + + async #hasCorrectContractClassIds(tx: Tx): Promise { + const contractClassLogs = tx.getContractClassLogs(); + for (const log of contractClassLogs) { + if (!ContractClassPublishedEvent.isContractClassPublishedEvent(log)) { + continue; + } + + let event; + try { + event = ContractClassPublishedEvent.fromLog(log); + } catch (e) { + this.#log.warn(`Rejecting tx ${tx.getTxHash()}: failed to parse contract class event: ${e}`); + return TX_ERROR_MALFORMED_CONTRACT_CLASS_LOG; + } + + try { + const { publicBytecodeCommitment } = await event.toContractClassPublicWithBytecodeCommitment(); + const computedClassId = await computeContractClassId({ + artifactHash: event.artifactHash, + privateFunctionsRoot: event.privateFunctionsRoot, + publicBytecodeCommitment, + }); + if (!computedClassId.equals(event.contractClassId)) { + this.#log.warn( + `Rejecting tx ${tx.getTxHash()}: contract class id mismatch. Claimed ${event.contractClassId}, computed ${computedClassId}`, + ); + return TX_ERROR_INCORRECT_CONTRACT_CLASS_ID; + } + } catch (e) { + this.#log.warn(`Rejecting tx ${tx.getTxHash()}: failed to compute contract class id: ${e}`); + return TX_ERROR_MALFORMED_CONTRACT_CLASS_LOG; + } + } + return undefined; + } } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts index 69d5bd9f0cab..39362bd39dd2 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts @@ -40,7 +40,7 @@ export class PhasesTxValidator implements TxValidator { // which are needed for public FPC flows, but fail if the account contract hasnt been deployed yet, // which is what we're trying to do as part of the current txs. // We only need to create/revert checkpoint here because of this addNewContracts call. - await this.contractsDB.addNewContracts(tx); + this.contractsDB.addNewContracts(tx); if (!tx.data.forPublic) { this.#log.debug( diff --git a/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.ts b/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.ts index 73234f5cc6a3..a7aea045ff8d 100644 --- a/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.ts +++ b/yarn-project/protocol-contracts/src/class-registry/contract_class_published_event.ts @@ -4,7 +4,7 @@ import { FieldReader } from '@aztec/foundation/serialize'; import { bufferFromFields } from '@aztec/stdlib/abi'; import { type ContractClassPublic, - computeContractClassId, + type ContractClassPublicWithCommitment, computePublicBytecodeCommitment, } from '@aztec/stdlib/contract'; import type { ContractClassLog } from '@aztec/stdlib/logs'; @@ -47,34 +47,25 @@ export class ContractClassPublishedEvent { ); } - async toContractClassPublic(): Promise { - const computedClassId = await computeContractClassId({ - artifactHash: this.artifactHash, - privateFunctionsRoot: this.privateFunctionsRoot, - publicBytecodeCommitment: await computePublicBytecodeCommitment(this.packedPublicBytecode), - }); - - if (!computedClassId.equals(this.contractClassId)) { - throw new Error( - `Invalid contract class id: computed ${computedClassId.toString()} but event broadcasted ${this.contractClassId.toString()}`, - ); - } - - if (this.version !== 1) { - throw new Error(`Unexpected contract class version ${this.version}`); - } - + /** Converts the event to a contract class, without computing or validating the bytecode commitment. */ + toContractClassPublic(): ContractClassPublic { return { id: this.contractClassId, artifactHash: this.artifactHash, packedBytecode: this.packedPublicBytecode, privateFunctionsRoot: this.privateFunctionsRoot, - version: this.version, + version: this.version as 1, privateFunctions: [], utilityFunctions: [], }; } + /** Converts the event to a contract class with its bytecode commitment (expensive). */ + async toContractClassPublicWithBytecodeCommitment(): Promise { + const publicBytecodeCommitment = await computePublicBytecodeCommitment(this.packedPublicBytecode); + return { ...this.toContractClassPublic(), publicBytecodeCommitment }; + } + public static extractContractClassEvents(logs: ContractClassLog[]): ContractClassPublishedEvent[] { return logs .filter((log: ContractClassLog) => ContractClassPublishedEvent.isContractClassPublishedEvent(log)) diff --git a/yarn-project/simulator/src/public/public_db_sources.ts b/yarn-project/simulator/src/public/public_db_sources.ts index 445949934694..144a39182563 100644 --- a/yarn-project/simulator/src/public/public_db_sources.ts +++ b/yarn-project/simulator/src/public/public_db_sources.ts @@ -55,10 +55,10 @@ export class PublicContractsDB implements PublicContractsDBInterface { this.log = createLogger('simulator:contracts-data-source', bindings); } - public async addContracts(contractDeploymentData: ContractDeploymentData): Promise { + public addContracts(contractDeploymentData: ContractDeploymentData): void { const currentState = this.getCurrentState(); - await this.addContractClassesFromEvents( + this.addContractClassesFromEvents( ContractClassPublishedEvent.extractContractClassEvents(contractDeploymentData.getContractClassLogs()), currentState, ); @@ -69,10 +69,10 @@ export class PublicContractsDB implements PublicContractsDBInterface { ); } - public async addNewContracts(tx: Tx): Promise { + public addNewContracts(tx: Tx): void { const contractDeploymentData = AllContractDeploymentData.fromTx(tx); - await this.addContracts(contractDeploymentData.getNonRevertibleContractDeploymentData()); - await this.addContracts(contractDeploymentData.getRevertibleContractDeploymentData()); + this.addContracts(contractDeploymentData.getNonRevertibleContractDeploymentData()); + this.addContracts(contractDeploymentData.getRevertibleContractDeploymentData()); } /** @@ -174,17 +174,15 @@ export class PublicContractsDB implements PublicContractsDBInterface { return await this.dataSource.getDebugFunctionName(address, selector); } - private async addContractClassesFromEvents( + private addContractClassesFromEvents( contractClassEvents: ContractClassPublishedEvent[], state: ContractsDbCheckpoint, ) { - await Promise.all( - contractClassEvents.map(async (event: ContractClassPublishedEvent) => { - this.log.debug(`Adding class ${event.contractClassId.toString()} to contract state`); - const contractClass = await event.toContractClassPublic(); - state.addClass(event.contractClassId, contractClass); - }), - ); + for (const event of contractClassEvents) { + this.log.debug(`Adding class ${event.contractClassId.toString()} to contract state`); + const contractClass = event.toContractClassPublic(); + state.addClass(event.contractClassId, contractClass); + } } private addContractInstancesFromEvents( diff --git a/yarn-project/simulator/src/public/public_processor/public_processor.test.ts b/yarn-project/simulator/src/public/public_processor/public_processor.test.ts index 23a019bb6080..abc3aedf918e 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.test.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.test.ts @@ -361,8 +361,8 @@ describe('public_processor', () => { // we want to confirm that even non-revertibles get cleared const contractClassId = await mockContractClassForTx(tx, /*revertible=*/ false); - publicTxSimulator.simulate.mockImplementation(async (simulatedTx: Tx) => { - await contractsDB.addNewContracts(simulatedTx); + publicTxSimulator.simulate.mockImplementation((simulatedTx: Tx) => { + contractsDB.addNewContracts(simulatedTx); throw new Error('Uncaught error'); }); diff --git a/yarn-project/simulator/src/public/public_processor/public_processor.ts b/yarn-project/simulator/src/public/public_processor/public_processor.ts index f8b708d0a568..29a4f6aaadbf 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.ts @@ -548,7 +548,7 @@ export class PublicProcessor implements Traceable { // Fee payment insertion has already been done. Do the rest. await this.doTreeInsertionsForPrivateOnlyTx(processedTx); - await this.contractsDB.addNewContracts(tx); + this.contractsDB.addNewContracts(tx); return [processedTx, undefined, []]; } diff --git a/yarn-project/simulator/src/public/public_tx_simulator/contract_provider_for_cpp.ts b/yarn-project/simulator/src/public/public_tx_simulator/contract_provider_for_cpp.ts index 6568a47228e9..9077b8676c1e 100644 --- a/yarn-project/simulator/src/public/public_tx_simulator/contract_provider_for_cpp.ts +++ b/yarn-project/simulator/src/public/public_tx_simulator/contract_provider_for_cpp.ts @@ -52,6 +52,7 @@ export class ContractProviderForCpp implements ContractProvider { return serializeWithMessagePack(contractClass); }; + // eslint-disable-next-line require-await public addContracts = async (contractDeploymentDataBuffer: Buffer): Promise => { this.log.trace(`Contract provider callback: addContracts`); @@ -62,7 +63,7 @@ export class ContractProviderForCpp implements ContractProvider { // Add contracts to the contracts DB this.log.trace(`Calling contractsDB.addContracts`); - await this.contractsDB.addContracts(contractDeploymentData); + this.contractsDB.addContracts(contractDeploymentData); }; public getBytecodeCommitment = async (classId: string): Promise => { diff --git a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts index c07f0d04945c..4e2b95c21c7c 100644 --- a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts +++ b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts @@ -401,7 +401,7 @@ export class PublicTxSimulator implements PublicTxSimulatorInterface { // However, things work as expected because later calls to getters on the hintingContractsDB // will pick up the new contracts and will generate the necessary hints. // So, a consumer of the hints will always see the new contracts. - await this.contractsDB.addContracts(context.nonRevertibleContractDeploymentData); + this.contractsDB.addContracts(context.nonRevertibleContractDeploymentData); } /** @@ -486,7 +486,7 @@ export class PublicTxSimulator implements PublicTxSimulatorInterface { // However, things work as expected because later calls to getters on the hintingContractsDB // will pick up the new contracts and will generate the necessary hints. // So, a consumer of the hints will always see the new contracts. - await this.contractsDB.addContracts(context.revertibleContractDeploymentData); + this.contractsDB.addContracts(context.revertibleContractDeploymentData); } private async payFee(context: PublicTxContext) { diff --git a/yarn-project/stdlib/src/contract/contract_class_id.test.ts b/yarn-project/stdlib/src/contract/contract_class_id.test.ts index 224957aa6431..04df3321ab02 100644 --- a/yarn-project/stdlib/src/contract/contract_class_id.test.ts +++ b/yarn-project/stdlib/src/contract/contract_class_id.test.ts @@ -1,6 +1,10 @@ import { Fr } from '@aztec/foundation/curves/bn254'; +import { createLogger } from '@aztec/foundation/log'; +import { elapsed } from '@aztec/foundation/timer'; import { FunctionSelector } from '../abi/function_selector.js'; +import { getBenchmarkContractArtifact, getTestContractArtifact, getTokenContractArtifact } from '../tests/fixtures.js'; +import { getContractClassFromArtifact } from './contract_class.js'; import { computeContractClassId } from './contract_class_id.js'; import type { ContractClass } from './interfaces/contract_class.js'; @@ -18,5 +22,20 @@ describe('ContractClass', () => { `"0x2926577ccab09f8e4600550792066ed9d6ce530a973ac2b81a36eaebee56ad44"`, ); }); + + it('calculates the contract class id for a real contract artifact', async () => { + const artifacts = [getBenchmarkContractArtifact(), getTokenContractArtifact(), getTestContractArtifact()]; + const logger = createLogger('stdlib:contract_class_id:test'); + + for (const artifact of artifacts) { + const contractClass = await getContractClassFromArtifact(artifact); + + const [ms, contractClassId] = await elapsed(computeContractClassId(contractClass)); + logger.info(`Computed contract class id ${contractClassId} in ${ms}ms`); + + expect(contractClassId.toString()).toHaveLength(66); // 0x + 64 hex chars + expect(contractClassId.toBigInt()).toBeGreaterThan(0n); + } + }); }); }); diff --git a/yarn-project/stdlib/src/tx/validator/error_texts.ts b/yarn-project/stdlib/src/tx/validator/error_texts.ts index 6a8326f032a8..bcc100c2d9b0 100644 --- a/yarn-project/stdlib/src/tx/validator/error_texts.ts +++ b/yarn-project/stdlib/src/tx/validator/error_texts.ts @@ -41,5 +41,13 @@ export const TX_ERROR_SIZE_ABOVE_LIMIT = 'Transaction size above size limit'; // Block header export const TX_ERROR_BLOCK_HEADER = 'Block header not found'; +// Contract instance +export const TX_ERROR_INCORRECT_CONTRACT_ADDRESS = 'Incorrect contract instance deployment address'; +export const TX_ERROR_MALFORMED_CONTRACT_INSTANCE_LOG = 'Failed to parse contract instance deployment log'; + +// Contract class +export const TX_ERROR_INCORRECT_CONTRACT_CLASS_ID = 'Incorrect contract class id'; +export const TX_ERROR_MALFORMED_CONTRACT_CLASS_LOG = 'Failed to parse contract class registration log'; + // General export const TX_ERROR_DURING_VALIDATION = 'Unexpected error during validation'; From e82ca40667d272b79b77baa31e1e4a93252ce3fa Mon Sep 17 00:00:00 2001 From: Gregorio Juliana Date: Fri, 20 Mar 2026 19:00:22 +0100 Subject: [PATCH 10/10] feat: sync poseidon browser (#21851) Broadens the check to ensure the sync version is used accross the browser, service workers, web workers and extension contexts. --- yarn-project/foundation/src/crypto/poseidon/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/foundation/src/crypto/poseidon/index.ts b/yarn-project/foundation/src/crypto/poseidon/index.ts index adbd7a4b4eb4..e5cf4ded640e 100644 --- a/yarn-project/foundation/src/crypto/poseidon/index.ts +++ b/yarn-project/foundation/src/crypto/poseidon/index.ts @@ -3,7 +3,7 @@ import { Barretenberg, BarretenbergSync } from '@aztec/bb.js'; import { Fr } from '../../curves/bn254/field.js'; import { type Fieldable, serializeToFields } from '../../serialize/serialize.js'; -const IS_BROWSER = typeof window !== 'undefined'; +const IS_BROWSER = typeof self !== 'undefined'; async function poseidon2HashFields(inputFields: Fr[]): Promise { if (IS_BROWSER) {