Skip to content

Commit 48b7ac4

Browse files
authoredJan 28, 2025
feat: Validate L1 config against L1 on startup (#11540)
Validates all L1 contract addresses and config (eg aztec slot duration) using the L1 rpc url and governance contract address. Once we clean up how we load config in the startup commands, it should be easy to change this so we load those settings instead of just validating them.
1 parent f77b11e commit 48b7ac4

20 files changed

+481
-62
lines changed
 

‎yarn-project/aztec/src/cli/cmds/start_archiver.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { Archiver, type ArchiverConfig, KVArchiverDataStore, archiverConfigMappings } from '@aztec/archiver';
1+
import {
2+
Archiver,
3+
type ArchiverConfig,
4+
KVArchiverDataStore,
5+
archiverConfigMappings,
6+
getArchiverConfigFromEnv,
7+
} from '@aztec/archiver';
28
import { createLogger } from '@aztec/aztec.js';
39
import { createBlobSinkClient } from '@aztec/blob-sink/client';
410
import { ArchiverApiSchema } from '@aztec/circuit-types';
@@ -8,6 +14,7 @@ import { createStore } from '@aztec/kv-store/lmdb';
814
import { getConfigEnvVars as getTelemetryClientConfig, initTelemetryClient } from '@aztec/telemetry-client';
915

1016
import { extractRelevantOptions } from '../util.js';
17+
import { validateL1Config } from '../validation.js';
1118

1219
/** Starts a standalone archiver. */
1320
export async function startArchiver(
@@ -24,6 +31,8 @@ export async function startArchiver(
2431
'archiver',
2532
);
2633

34+
await validateL1Config({ ...getArchiverConfigFromEnv(), ...archiverConfig });
35+
2736
const storeLog = createLogger('archiver:lmdb');
2837
const store = await createStore('archiver', archiverConfig, storeLog);
2938
const archiverStore = new KVArchiverDataStore(store, archiverConfig.maxLogs);

‎yarn-project/aztec/src/cli/cmds/start_node.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { aztecNodeConfigMappings } from '@aztec/aztec-node';
1+
import { aztecNodeConfigMappings, getConfigEnvVars as getNodeConfigEnvVars } from '@aztec/aztec-node';
22
import { AztecNodeApiSchema, P2PApiSchema, type PXE } from '@aztec/circuit-types';
33
import { NULL_KEY } from '@aztec/ethereum';
44
import { type NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server';
@@ -13,6 +13,7 @@ import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
1313

1414
import { createAztecNode, deployContractsToL1 } from '../../sandbox.js';
1515
import { extractNamespacedOptions, extractRelevantOptions } from '../util.js';
16+
import { validateL1Config } from '../validation.js';
1617

1718
export async function startNode(
1819
options: any,
@@ -42,11 +43,18 @@ export async function startNode(
4243
} else {
4344
throw new Error('--node.publisherPrivateKey or --l1-mnemonic is required to deploy L1 contracts');
4445
}
46+
// REFACTOR: We should not be calling a method from sandbox on the prod start flow
4547
await deployContractsToL1(nodeConfig, account!, undefined, {
4648
assumeProvenThroughBlockNumber: nodeSpecificOptions.assumeProvenThroughBlockNumber,
4749
salt: nodeSpecificOptions.deployAztecContractsSalt,
4850
});
4951
}
52+
// If not deploying, validate that the addresses and config provided are correct.
53+
// Eventually, we should be able to dynamically load this just by having the L1 governance address,
54+
// instead of only validating the config the user has entered.
55+
else {
56+
await validateL1Config({ ...getNodeConfigEnvVars(), ...nodeConfig });
57+
}
5058

5159
// if no publisher private key, then use l1Mnemonic
5260
if (!options.archiver) {

‎yarn-project/aztec/src/cli/cmds/start_prover_node.ts

+6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { initTelemetryClient, telemetryClientConfigMappings } from '@aztec/telem
1414
import { mnemonicToAccount } from 'viem/accounts';
1515

1616
import { extractRelevantOptions } from '../util.js';
17+
import { validateL1Config } from '../validation.js';
1718
import { startProverBroker } from './start_prover_broker.js';
1819

1920
export async function startProverNode(
@@ -58,6 +59,11 @@ export async function startProverNode(
5859
proverConfig.l1Contracts = await createAztecNodeClient(nodeUrl).getL1ContractAddresses();
5960
}
6061

62+
// If we create an archiver here, validate the L1 config
63+
if (options.archiver) {
64+
await validateL1Config(proverConfig);
65+
}
66+
6167
const telemetry = initTelemetryClient(extractRelevantOptions(options, telemetryClientConfigMappings, 'tel'));
6268

6369
let broker: ProvingJobBroker;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { type AztecNodeConfig, aztecNodeConfigMappings } from '@aztec/aztec-node';
2+
import { EthAddress } from '@aztec/circuits.js';
3+
import { startAnvil } from '@aztec/ethereum/test';
4+
import { getDefaultConfig } from '@aztec/foundation/config';
5+
6+
import { type Anvil } from '@viem/anvil';
7+
import { mnemonicToAccount } from 'viem/accounts';
8+
9+
import { DefaultMnemonic } from '../mnemonic.js';
10+
import { deployContractsToL1 } from '../sandbox.js';
11+
import { validateL1Config } from './validation.js';
12+
13+
describe('validation', () => {
14+
describe('L1 config', () => {
15+
let anvil: Anvil;
16+
let l1RpcUrl: string;
17+
let nodeConfig: AztecNodeConfig;
18+
19+
beforeAll(async () => {
20+
({ anvil, rpcUrl: l1RpcUrl } = await startAnvil());
21+
22+
nodeConfig = { ...getDefaultConfig(aztecNodeConfigMappings), l1RpcUrl };
23+
nodeConfig.aztecSlotDuration = 72; // Tweak config so we don't have just defaults
24+
const account = mnemonicToAccount(DefaultMnemonic);
25+
const deployed = await deployContractsToL1(nodeConfig, account, undefined, { salt: 1 });
26+
nodeConfig.l1Contracts = deployed;
27+
});
28+
29+
afterAll(async () => {
30+
await anvil.stop();
31+
});
32+
33+
it('validates correct config', async () => {
34+
await validateL1Config(nodeConfig);
35+
});
36+
37+
it('throws on invalid l1 settings', async () => {
38+
await expect(validateL1Config({ ...nodeConfig, aztecSlotDuration: 96 })).rejects.toThrow(/aztecSlotDuration/);
39+
});
40+
41+
it('throws on mismatching l1 addresses', async () => {
42+
const wrongL1Contracts = { ...nodeConfig.l1Contracts, feeJuicePortalAddress: EthAddress.random() };
43+
const wrongConfig = { ...nodeConfig, l1Contracts: wrongL1Contracts };
44+
await expect(validateL1Config(wrongConfig)).rejects.toThrow(/feeJuicePortalAddress/);
45+
});
46+
});
47+
});
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
type L1ContractAddresses,
3+
type L1ContractsConfig,
4+
getL1ContractsAddresses,
5+
getL1ContractsConfig,
6+
getPublicClient,
7+
} from '@aztec/ethereum';
8+
9+
/**
10+
* Connects to L1 using the provided L1 RPC URL and reads all addresses and settings from the governance
11+
* contract. For each key, compares it against the provided config (if it is not empty) and throws on mismatches.
12+
*/
13+
export async function validateL1Config(
14+
config: L1ContractsConfig & { l1Contracts: L1ContractAddresses } & { l1ChainId: number; l1RpcUrl: string },
15+
) {
16+
const publicClient = getPublicClient(config);
17+
const actualAddresses = await getL1ContractsAddresses(publicClient, config.l1Contracts.governanceAddress);
18+
19+
for (const keyStr in actualAddresses) {
20+
const key = keyStr as keyof Awaited<ReturnType<typeof getL1ContractsAddresses>>;
21+
const actual = actualAddresses[key];
22+
const expected = config.l1Contracts[key];
23+
24+
if (expected !== undefined && !expected.isZero() && !actual.equals(expected)) {
25+
throw new Error(`Expected L1 contract address ${key} to be ${expected} but found ${actual}`);
26+
}
27+
}
28+
29+
const actualConfig = await getL1ContractsConfig(publicClient, actualAddresses);
30+
for (const keyStr in actualConfig) {
31+
const key = keyStr as keyof Awaited<ReturnType<typeof getL1ContractsConfig>> & keyof L1ContractsConfig;
32+
const actual = actualConfig[key];
33+
const expected = config[key];
34+
if (expected !== undefined && actual !== expected) {
35+
throw new Error(`Expected L1 setting ${key} to be ${expected} but found ${actual}`);
36+
}
37+
}
38+
}

‎yarn-project/aztec/src/sandbox.ts

+13-39
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
#!/usr/bin/env -S node --no-warnings
22
import { type AztecNodeConfig, AztecNodeService, getConfigEnvVars } from '@aztec/aztec-node';
3-
import { AnvilTestWatcher, EthCheatCodes, SignerlessWallet, retryUntil } from '@aztec/aztec.js';
3+
import { AnvilTestWatcher, EthCheatCodes, SignerlessWallet } from '@aztec/aztec.js';
44
import { DefaultMultiCallEntrypoint } from '@aztec/aztec.js/entrypoint';
55
import { type BlobSinkClientInterface, createBlobSinkClient } from '@aztec/blob-sink/client';
66
import { type AztecNode } from '@aztec/circuit-types';
77
import { setupCanonicalL2FeeJuice } from '@aztec/cli/setup-contracts';
88
import {
9-
type DeployL1Contracts,
109
NULL_KEY,
1110
createEthereumChain,
1211
deployL1Contracts,
1312
getL1ContractsConfigEnvVars,
13+
waitForPublicClient,
1414
} from '@aztec/ethereum';
1515
import { createLogger } from '@aztec/foundation/log';
1616
import { getVKTreeRoot } from '@aztec/noir-protocol-circuits-types/vks';
@@ -32,39 +32,6 @@ const logger = createLogger('sandbox');
3232

3333
const localAnvil = foundry;
3434

35-
/**
36-
* Helper function that waits for the Ethereum RPC server to respond before deploying L1 contracts.
37-
*/
38-
async function waitThenDeploy(config: AztecNodeConfig, deployFunction: () => Promise<DeployL1Contracts>) {
39-
const chain = createEthereumChain(config.l1RpcUrl, config.l1ChainId);
40-
// wait for ETH RPC to respond to a request.
41-
const publicClient = createPublicClient({
42-
chain: chain.chainInfo,
43-
transport: httpViemTransport(chain.rpcUrl),
44-
});
45-
const l1ChainID = await retryUntil(
46-
async () => {
47-
let chainId = 0;
48-
try {
49-
chainId = await publicClient.getChainId();
50-
} catch (err) {
51-
logger.warn(`Failed to connect to Ethereum node at ${chain.rpcUrl}. Retrying...`);
52-
}
53-
return chainId;
54-
},
55-
'isEthRpcReady',
56-
600,
57-
1,
58-
);
59-
60-
if (!l1ChainID) {
61-
throw Error(`Ethereum node unresponsive at ${chain.rpcUrl}.`);
62-
}
63-
64-
// Deploy L1 contracts
65-
return await deployFunction();
66-
}
67-
6835
/**
6936
* Function to deploy our L1 contracts to the sandbox L1
7037
* @param aztecNodeConfig - The Aztec Node Config
@@ -80,15 +47,22 @@ export async function deployContractsToL1(
8047
? createEthereumChain(aztecNodeConfig.l1RpcUrl, aztecNodeConfig.l1ChainId)
8148
: { chainInfo: localAnvil };
8249

83-
const l1Contracts = await waitThenDeploy(aztecNodeConfig, async () =>
84-
deployL1Contracts(aztecNodeConfig.l1RpcUrl, hdAccount, chain.chainInfo, contractDeployLogger, {
50+
await waitForPublicClient(aztecNodeConfig);
51+
52+
const l1Contracts = await deployL1Contracts(
53+
aztecNodeConfig.l1RpcUrl,
54+
hdAccount,
55+
chain.chainInfo,
56+
contractDeployLogger,
57+
{
58+
...getL1ContractsConfigEnvVars(), // TODO: We should not need to be loading config from env again, caller should handle this
59+
...aztecNodeConfig,
8560
l2FeeJuiceAddress: ProtocolContractAddress.FeeJuice,
8661
vkTreeRoot: await getVKTreeRoot(),
8762
protocolContractTreeRoot,
8863
assumeProvenThrough: opts.assumeProvenThroughBlockNumber,
8964
salt: opts.salt,
90-
...getL1ContractsConfigEnvVars(),
91-
}),
65+
},
9266
);
9367

9468
aztecNodeConfig.l1Contracts = l1Contracts.l1ContractAddresses;
File renamed without changes.

‎yarn-project/ethereum/src/client.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { type Logger } from '@aztec/foundation/log';
2+
import { retryUntil } from '@aztec/foundation/retry';
3+
4+
import { createPublicClient, http } from 'viem';
5+
6+
import { createEthereumChain } from './chain.js';
7+
import { type ViemPublicClient } from './types.js';
8+
9+
type Config = {
10+
/** The RPC Url of the ethereum host. */
11+
l1RpcUrl: string;
12+
/** The chain ID of the ethereum host. */
13+
l1ChainId: number;
14+
/** The polling interval viem uses in ms */
15+
viemPollingIntervalMS?: number;
16+
};
17+
18+
// TODO: Use these methods to abstract the creation of viem clients.
19+
20+
/** Returns a viem public client given the L1 config. */
21+
export function getPublicClient(config: Config): ViemPublicClient {
22+
const chain = createEthereumChain(config.l1RpcUrl, config.l1ChainId);
23+
return createPublicClient({
24+
chain: chain.chainInfo,
25+
transport: http(chain.rpcUrl),
26+
pollingInterval: config.viemPollingIntervalMS,
27+
});
28+
}
29+
30+
/** Returns a viem public client after waiting for the L1 RPC node to become available. */
31+
export async function waitForPublicClient(config: Config, logger?: Logger): Promise<ViemPublicClient> {
32+
const client = getPublicClient(config);
33+
await waitForRpc(client, config, logger);
34+
return client;
35+
}
36+
37+
async function waitForRpc(client: ViemPublicClient, config: Config, logger?: Logger) {
38+
const l1ChainId = await retryUntil(
39+
async () => {
40+
let chainId = 0;
41+
try {
42+
chainId = await client.getChainId();
43+
} catch (err) {
44+
logger?.warn(`Failed to connect to Ethereum node at ${config.l1RpcUrl}. Retrying...`);
45+
}
46+
return chainId;
47+
},
48+
`L1 RPC url at ${config.l1RpcUrl}`,
49+
600,
50+
1,
51+
);
52+
53+
if (l1ChainId !== config.l1ChainId) {
54+
throw new Error(`Ethereum node at ${config.l1RpcUrl} has chain ID ${l1ChainId} but expected ${config.l1ChainId}`);
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { EthAddress } from '@aztec/foundation/eth-address';
2+
import { GovernanceAbi } from '@aztec/l1-artifacts';
3+
4+
import {
5+
type Chain,
6+
type GetContractReturnType,
7+
type Hex,
8+
type HttpTransport,
9+
type PublicClient,
10+
getContract,
11+
} from 'viem';
12+
13+
import { type L1ContractAddresses } from '../l1_contract_addresses.js';
14+
import { GovernanceProposerContract } from './governance_proposer.js';
15+
16+
export type L1GovernanceContractAddresses = Pick<
17+
L1ContractAddresses,
18+
'governanceAddress' | 'rollupAddress' | 'registryAddress' | 'governanceProposerAddress'
19+
>;
20+
21+
export class GovernanceContract {
22+
private readonly governance: GetContractReturnType<typeof GovernanceAbi, PublicClient<HttpTransport, Chain>>;
23+
24+
constructor(public readonly client: PublicClient<HttpTransport, Chain>, address: Hex) {
25+
this.governance = getContract({ address, abi: GovernanceAbi, client });
26+
}
27+
28+
public get address() {
29+
return EthAddress.fromString(this.governance.address);
30+
}
31+
32+
public async getProposer() {
33+
const governanceProposerAddress = EthAddress.fromString(await this.governance.read.governanceProposer());
34+
return new GovernanceProposerContract(this.client, governanceProposerAddress.toString());
35+
}
36+
37+
public async getGovernanceAddresses(): Promise<L1GovernanceContractAddresses> {
38+
const governanceProposer = await this.getProposer();
39+
const [rollupAddress, registryAddress] = await Promise.all([
40+
governanceProposer.getRollupAddress(),
41+
governanceProposer.getRegistryAddress(),
42+
]);
43+
return {
44+
governanceAddress: this.address,
45+
rollupAddress,
46+
registryAddress,
47+
governanceProposerAddress: governanceProposer.address,
48+
};
49+
}
50+
}

0 commit comments

Comments
 (0)