Skip to content

Commit

Permalink
feat: Validate L1 config against L1 on startup (#11540)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
spalladino authored Jan 28, 2025
1 parent f77b11e commit 48b7ac4
Show file tree
Hide file tree
Showing 20 changed files with 481 additions and 62 deletions.
11 changes: 10 additions & 1 deletion yarn-project/aztec/src/cli/cmds/start_archiver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Archiver, type ArchiverConfig, KVArchiverDataStore, archiverConfigMappings } from '@aztec/archiver';
import {
Archiver,
type ArchiverConfig,
KVArchiverDataStore,
archiverConfigMappings,
getArchiverConfigFromEnv,
} from '@aztec/archiver';
import { createLogger } from '@aztec/aztec.js';
import { createBlobSinkClient } from '@aztec/blob-sink/client';
import { ArchiverApiSchema } from '@aztec/circuit-types';
Expand All @@ -8,6 +14,7 @@ import { createStore } from '@aztec/kv-store/lmdb';
import { getConfigEnvVars as getTelemetryClientConfig, initTelemetryClient } from '@aztec/telemetry-client';

import { extractRelevantOptions } from '../util.js';
import { validateL1Config } from '../validation.js';

/** Starts a standalone archiver. */
export async function startArchiver(
Expand All @@ -24,6 +31,8 @@ export async function startArchiver(
'archiver',
);

await validateL1Config({ ...getArchiverConfigFromEnv(), ...archiverConfig });

const storeLog = createLogger('archiver:lmdb');
const store = await createStore('archiver', archiverConfig, storeLog);
const archiverStore = new KVArchiverDataStore(store, archiverConfig.maxLogs);
Expand Down
10 changes: 9 additions & 1 deletion yarn-project/aztec/src/cli/cmds/start_node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { aztecNodeConfigMappings } from '@aztec/aztec-node';
import { aztecNodeConfigMappings, getConfigEnvVars as getNodeConfigEnvVars } from '@aztec/aztec-node';
import { AztecNodeApiSchema, P2PApiSchema, type PXE } from '@aztec/circuit-types';
import { NULL_KEY } from '@aztec/ethereum';
import { type NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server';
Expand All @@ -13,6 +13,7 @@ import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';

import { createAztecNode, deployContractsToL1 } from '../../sandbox.js';
import { extractNamespacedOptions, extractRelevantOptions } from '../util.js';
import { validateL1Config } from '../validation.js';

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

// if no publisher private key, then use l1Mnemonic
if (!options.archiver) {
Expand Down
6 changes: 6 additions & 0 deletions yarn-project/aztec/src/cli/cmds/start_prover_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { initTelemetryClient, telemetryClientConfigMappings } from '@aztec/telem
import { mnemonicToAccount } from 'viem/accounts';

import { extractRelevantOptions } from '../util.js';
import { validateL1Config } from '../validation.js';
import { startProverBroker } from './start_prover_broker.js';

export async function startProverNode(
Expand Down Expand Up @@ -58,6 +59,11 @@ export async function startProverNode(
proverConfig.l1Contracts = await createAztecNodeClient(nodeUrl).getL1ContractAddresses();
}

// If we create an archiver here, validate the L1 config
if (options.archiver) {
await validateL1Config(proverConfig);
}

const telemetry = initTelemetryClient(extractRelevantOptions(options, telemetryClientConfigMappings, 'tel'));

let broker: ProvingJobBroker;
Expand Down
47 changes: 47 additions & 0 deletions yarn-project/aztec/src/cli/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type AztecNodeConfig, aztecNodeConfigMappings } from '@aztec/aztec-node';
import { EthAddress } from '@aztec/circuits.js';
import { startAnvil } from '@aztec/ethereum/test';
import { getDefaultConfig } from '@aztec/foundation/config';

import { type Anvil } from '@viem/anvil';
import { mnemonicToAccount } from 'viem/accounts';

import { DefaultMnemonic } from '../mnemonic.js';
import { deployContractsToL1 } from '../sandbox.js';
import { validateL1Config } from './validation.js';

describe('validation', () => {
describe('L1 config', () => {
let anvil: Anvil;
let l1RpcUrl: string;
let nodeConfig: AztecNodeConfig;

beforeAll(async () => {
({ anvil, rpcUrl: l1RpcUrl } = await startAnvil());

nodeConfig = { ...getDefaultConfig(aztecNodeConfigMappings), l1RpcUrl };
nodeConfig.aztecSlotDuration = 72; // Tweak config so we don't have just defaults
const account = mnemonicToAccount(DefaultMnemonic);
const deployed = await deployContractsToL1(nodeConfig, account, undefined, { salt: 1 });
nodeConfig.l1Contracts = deployed;
});

afterAll(async () => {
await anvil.stop();
});

it('validates correct config', async () => {
await validateL1Config(nodeConfig);
});

it('throws on invalid l1 settings', async () => {
await expect(validateL1Config({ ...nodeConfig, aztecSlotDuration: 96 })).rejects.toThrow(/aztecSlotDuration/);
});

it('throws on mismatching l1 addresses', async () => {
const wrongL1Contracts = { ...nodeConfig.l1Contracts, feeJuicePortalAddress: EthAddress.random() };
const wrongConfig = { ...nodeConfig, l1Contracts: wrongL1Contracts };
await expect(validateL1Config(wrongConfig)).rejects.toThrow(/feeJuicePortalAddress/);
});
});
});
38 changes: 38 additions & 0 deletions yarn-project/aztec/src/cli/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
type L1ContractAddresses,
type L1ContractsConfig,
getL1ContractsAddresses,
getL1ContractsConfig,
getPublicClient,
} from '@aztec/ethereum';

/**
* Connects to L1 using the provided L1 RPC URL and reads all addresses and settings from the governance
* contract. For each key, compares it against the provided config (if it is not empty) and throws on mismatches.
*/
export async function validateL1Config(
config: L1ContractsConfig & { l1Contracts: L1ContractAddresses } & { l1ChainId: number; l1RpcUrl: string },
) {
const publicClient = getPublicClient(config);
const actualAddresses = await getL1ContractsAddresses(publicClient, config.l1Contracts.governanceAddress);

for (const keyStr in actualAddresses) {
const key = keyStr as keyof Awaited<ReturnType<typeof getL1ContractsAddresses>>;
const actual = actualAddresses[key];
const expected = config.l1Contracts[key];

if (expected !== undefined && !expected.isZero() && !actual.equals(expected)) {
throw new Error(`Expected L1 contract address ${key} to be ${expected} but found ${actual}`);
}
}

const actualConfig = await getL1ContractsConfig(publicClient, actualAddresses);
for (const keyStr in actualConfig) {
const key = keyStr as keyof Awaited<ReturnType<typeof getL1ContractsConfig>> & keyof L1ContractsConfig;
const actual = actualConfig[key];
const expected = config[key];
if (expected !== undefined && actual !== expected) {
throw new Error(`Expected L1 setting ${key} to be ${expected} but found ${actual}`);
}
}
}
52 changes: 13 additions & 39 deletions yarn-project/aztec/src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
#!/usr/bin/env -S node --no-warnings
import { type AztecNodeConfig, AztecNodeService, getConfigEnvVars } from '@aztec/aztec-node';
import { AnvilTestWatcher, EthCheatCodes, SignerlessWallet, retryUntil } from '@aztec/aztec.js';
import { AnvilTestWatcher, EthCheatCodes, SignerlessWallet } from '@aztec/aztec.js';
import { DefaultMultiCallEntrypoint } from '@aztec/aztec.js/entrypoint';
import { type BlobSinkClientInterface, createBlobSinkClient } from '@aztec/blob-sink/client';
import { type AztecNode } from '@aztec/circuit-types';
import { setupCanonicalL2FeeJuice } from '@aztec/cli/setup-contracts';
import {
type DeployL1Contracts,
NULL_KEY,
createEthereumChain,
deployL1Contracts,
getL1ContractsConfigEnvVars,
waitForPublicClient,
} from '@aztec/ethereum';
import { createLogger } from '@aztec/foundation/log';
import { getVKTreeRoot } from '@aztec/noir-protocol-circuits-types/vks';
Expand All @@ -32,39 +32,6 @@ const logger = createLogger('sandbox');

const localAnvil = foundry;

/**
* Helper function that waits for the Ethereum RPC server to respond before deploying L1 contracts.
*/
async function waitThenDeploy(config: AztecNodeConfig, deployFunction: () => Promise<DeployL1Contracts>) {
const chain = createEthereumChain(config.l1RpcUrl, config.l1ChainId);
// wait for ETH RPC to respond to a request.
const publicClient = createPublicClient({
chain: chain.chainInfo,
transport: httpViemTransport(chain.rpcUrl),
});
const l1ChainID = await retryUntil(
async () => {
let chainId = 0;
try {
chainId = await publicClient.getChainId();
} catch (err) {
logger.warn(`Failed to connect to Ethereum node at ${chain.rpcUrl}. Retrying...`);
}
return chainId;
},
'isEthRpcReady',
600,
1,
);

if (!l1ChainID) {
throw Error(`Ethereum node unresponsive at ${chain.rpcUrl}.`);
}

// Deploy L1 contracts
return await deployFunction();
}

/**
* Function to deploy our L1 contracts to the sandbox L1
* @param aztecNodeConfig - The Aztec Node Config
Expand All @@ -80,15 +47,22 @@ export async function deployContractsToL1(
? createEthereumChain(aztecNodeConfig.l1RpcUrl, aztecNodeConfig.l1ChainId)
: { chainInfo: localAnvil };

const l1Contracts = await waitThenDeploy(aztecNodeConfig, async () =>
deployL1Contracts(aztecNodeConfig.l1RpcUrl, hdAccount, chain.chainInfo, contractDeployLogger, {
await waitForPublicClient(aztecNodeConfig);

const l1Contracts = await deployL1Contracts(
aztecNodeConfig.l1RpcUrl,
hdAccount,
chain.chainInfo,
contractDeployLogger,
{
...getL1ContractsConfigEnvVars(), // TODO: We should not need to be loading config from env again, caller should handle this
...aztecNodeConfig,
l2FeeJuiceAddress: ProtocolContractAddress.FeeJuice,
vkTreeRoot: await getVKTreeRoot(),
protocolContractTreeRoot,
assumeProvenThrough: opts.assumeProvenThroughBlockNumber,
salt: opts.salt,
...getL1ContractsConfigEnvVars(),
}),
},
);

aztecNodeConfig.l1Contracts = l1Contracts.l1ContractAddresses;
Expand Down
File renamed without changes.
56 changes: 56 additions & 0 deletions yarn-project/ethereum/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { type Logger } from '@aztec/foundation/log';
import { retryUntil } from '@aztec/foundation/retry';

import { createPublicClient, http } from 'viem';

import { createEthereumChain } from './chain.js';
import { type ViemPublicClient } from './types.js';

type Config = {
/** The RPC Url of the ethereum host. */
l1RpcUrl: string;
/** The chain ID of the ethereum host. */
l1ChainId: number;
/** The polling interval viem uses in ms */
viemPollingIntervalMS?: number;
};

// TODO: Use these methods to abstract the creation of viem clients.

/** Returns a viem public client given the L1 config. */
export function getPublicClient(config: Config): ViemPublicClient {
const chain = createEthereumChain(config.l1RpcUrl, config.l1ChainId);
return createPublicClient({
chain: chain.chainInfo,
transport: http(chain.rpcUrl),
pollingInterval: config.viemPollingIntervalMS,
});
}

/** Returns a viem public client after waiting for the L1 RPC node to become available. */
export async function waitForPublicClient(config: Config, logger?: Logger): Promise<ViemPublicClient> {
const client = getPublicClient(config);
await waitForRpc(client, config, logger);
return client;
}

async function waitForRpc(client: ViemPublicClient, config: Config, logger?: Logger) {
const l1ChainId = await retryUntil(
async () => {
let chainId = 0;
try {
chainId = await client.getChainId();
} catch (err) {
logger?.warn(`Failed to connect to Ethereum node at ${config.l1RpcUrl}. Retrying...`);
}
return chainId;
},
`L1 RPC url at ${config.l1RpcUrl}`,
600,
1,
);

if (l1ChainId !== config.l1ChainId) {
throw new Error(`Ethereum node at ${config.l1RpcUrl} has chain ID ${l1ChainId} but expected ${config.l1ChainId}`);
}
}
50 changes: 50 additions & 0 deletions yarn-project/ethereum/src/contracts/governance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { EthAddress } from '@aztec/foundation/eth-address';
import { GovernanceAbi } from '@aztec/l1-artifacts';

import {
type Chain,
type GetContractReturnType,
type Hex,
type HttpTransport,
type PublicClient,
getContract,
} from 'viem';

import { type L1ContractAddresses } from '../l1_contract_addresses.js';
import { GovernanceProposerContract } from './governance_proposer.js';

export type L1GovernanceContractAddresses = Pick<
L1ContractAddresses,
'governanceAddress' | 'rollupAddress' | 'registryAddress' | 'governanceProposerAddress'
>;

export class GovernanceContract {
private readonly governance: GetContractReturnType<typeof GovernanceAbi, PublicClient<HttpTransport, Chain>>;

constructor(public readonly client: PublicClient<HttpTransport, Chain>, address: Hex) {
this.governance = getContract({ address, abi: GovernanceAbi, client });
}

public get address() {
return EthAddress.fromString(this.governance.address);
}

public async getProposer() {
const governanceProposerAddress = EthAddress.fromString(await this.governance.read.governanceProposer());
return new GovernanceProposerContract(this.client, governanceProposerAddress.toString());
}

public async getGovernanceAddresses(): Promise<L1GovernanceContractAddresses> {
const governanceProposer = await this.getProposer();
const [rollupAddress, registryAddress] = await Promise.all([
governanceProposer.getRollupAddress(),
governanceProposer.getRegistryAddress(),
]);
return {
governanceAddress: this.address,
rollupAddress,
registryAddress,
governanceProposerAddress: governanceProposer.address,
};
}
}
Loading

0 comments on commit 48b7ac4

Please sign in to comment.