Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 168 additions & 2 deletions yarn-project/aztec-node/src/aztec-node/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { TestCircuitVerifier } from '@aztec/bb-prover';
import { EpochCache } from '@aztec/epoch-cache';
import type { RollupContract } from '@aztec/ethereum/contracts';
import type { EthCheatCodes } from '@aztec/ethereum/test';
import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { Fr } from '@aztec/foundation/curves/bn254';
import { EthAddress } from '@aztec/foundation/eth-address';
import { BadRequestError } from '@aztec/foundation/json-rpc';
import type { Hex } from '@aztec/foundation/string';
import { DateProvider } from '@aztec/foundation/timer';
import { DateProvider, TestDateProvider } from '@aztec/foundation/timer';
import { unfreeze } from '@aztec/foundation/types';
import { type KeyStore, KeystoreManager, RemoteSigner, type ValidatorKeyStore } from '@aztec/node-keystore';
import { getVKTreeRoot } from '@aztec/noir-protocol-circuits-types/vk-tree';
import type { P2P } from '@aztec/p2p';
import { protocolContractsHash } from '@aztec/protocol-contracts';
import { computeFeePayerBalanceLeafSlot } from '@aztec/protocol-contracts/fee-juice';
import type { GlobalVariableBuilder, SequencerClient } from '@aztec/sequencer-client';
import type { GlobalVariableBuilder, Sequencer, SequencerClient } from '@aztec/sequencer-client';
import type { SlasherClientInterface } from '@aztec/slasher';
import { AztecAddress } from '@aztec/stdlib/aztec-address';
import { BlockHash, type BlockParameter, CheckpointedL2Block, L2Block, type L2BlockSource } from '@aztec/stdlib/block';
Expand Down Expand Up @@ -200,6 +201,7 @@ describe('aztec node', () => {
getPackageVersion() ?? '',
new TestCircuitVerifier(),
new TestCircuitVerifier(),
new DateProvider(),
);
});

Expand Down Expand Up @@ -742,6 +744,7 @@ describe('aztec node', () => {
getPackageVersion() ?? '',
new TestCircuitVerifier(),
new TestCircuitVerifier(),
new DateProvider(),
undefined,
undefined,
undefined,
Expand Down Expand Up @@ -931,6 +934,7 @@ describe('aztec node', () => {
getPackageVersion() ?? '',
new TestCircuitVerifier(),
new TestCircuitVerifier(),
new DateProvider(),
undefined,
undefined,
undefined,
Expand Down Expand Up @@ -958,6 +962,168 @@ describe('aztec node', () => {
});
});

describe('time manipulation', () => {
const INITIAL_MIN_TXS_PER_BLOCK = 1;

let mockEthCheatCodes: MockProxy<EthCheatCodes>;
let sequencerClient: MockProxy<SequencerClient>;
let sequencer: MockProxy<Sequencer>;
let testDateProvider: TestDateProvider;
let nodeWithSequencer: AztecNodeService;

beforeEach(() => {
mockEthCheatCodes = mock<EthCheatCodes>();
sequencer = mock<Sequencer>();
sequencer.getConfig.mockReturnValue({ minTxsPerBlock: INITIAL_MIN_TXS_PER_BLOCK } as any);

sequencerClient = mock<SequencerClient>();
sequencerClient.getSequencer.mockReturnValue(sequencer);
sequencerClient.trigger.mockReturnValue(Promise.resolve());

testDateProvider = new TestDateProvider();

nodeWithSequencer = new AztecNodeService(
nodeConfig,
p2p,
l2BlockSource,
mock(),
mock(),
mock(),
worldState,
sequencerClient,
undefined,
undefined,
undefined,
undefined,
12345,
rollupVersion.toNumber(),
globalVariablesBuilder,
epochCache,
getPackageVersion() ?? '',
new TestCircuitVerifier(),
new TestCircuitVerifier(),
testDateProvider,
);

// Pre-inject mock to avoid #getEthCheatCodes() creating a real EthCheatCodes with HTTP clients
(nodeWithSequencer as any).ethCheatCodes = mockEthCheatCodes;
});

// Slot calculation: slot = (timestamp - l1GenesisTime) / slotDuration
// With genesis=1000, slotDuration=12: timestamp 1060 → slot 5, timestamp 1120 → slot 10
const l1Constants = { ...EmptyL1RollupConstants, l1GenesisTime: 1000n, slotDuration: 12 };

const makeBlockInSlot = (slot: number) =>
L2Block.empty(
BlockHeader.empty({
globalVariables: GlobalVariables.empty({ slotNumber: SlotNumber(slot) }),
}),
);

/** Simulates block number advancing from `from` to `to` after the first call. */
const mockBlockNumberAdvancing = (from: number, to: number) => {
let callCount = 0;
l2BlockSource.getBlockNumber.mockImplementation(() => {
callCount++;
return Promise.resolve(callCount > 1 ? BlockNumber(to) : BlockNumber(from));
});
};

describe('mineBlock', () => {
it('throws when no sequencer is running', async () => {
// The default `node` has no sequencer (undefined)
(node as any).ethCheatCodes = mockEthCheatCodes;
await expect(node.mineBlock()).rejects.toThrow('Cannot mine block: no sequencer is running');
});

// mineBlock slot-handling logic (new slot and same slot) is tested in e2e_cheat_codes.test.ts

it('throws when currentSlot is behind lastBlockSlot', async () => {
mockEthCheatCodes.evmMine.mockResolvedValue();
// Timestamp 1036 → slot (1036-1000)/12 = 3, behind latest block's slot 5
mockEthCheatCodes.lastBlockTimestamp.mockResolvedValue(1036);
l2BlockSource.getL1Constants.mockResolvedValue(l1Constants);
l2BlockSource.getL2Block.mockResolvedValue(makeBlockInSlot(5));
l2BlockSource.getBlockNumber.mockResolvedValue(BlockNumber(5));

await expect(nodeWithSequencer.mineBlock()).rejects.toThrow("Current slot 3 is behind the last block's slot 5");
});

it('restores minTxsPerBlock after successful block production', async () => {
mockEthCheatCodes.evmMine.mockResolvedValue();
mockEthCheatCodes.lastBlockTimestamp.mockResolvedValue(1120);
l2BlockSource.getL1Constants.mockResolvedValue(l1Constants);
l2BlockSource.getL2Block.mockResolvedValue(makeBlockInSlot(5));
mockBlockNumberAdvancing(5, 6);

await nodeWithSequencer.mineBlock();

const updateCalls = sequencerClient.updateConfig.mock.calls;
expect(updateCalls[0][0]).toEqual({ minTxsPerBlock: 0 });
// Last call to update calls should revert the value to the original
expect(updateCalls[1][0]).toEqual({ minTxsPerBlock: INITIAL_MIN_TXS_PER_BLOCK });
});
});

describe('setNextBlockTimestamp', () => {
it('sets timestamp on ethCheatCodes and updates dateProvider', async () => {
mockEthCheatCodes.setNextBlockTimestamp.mockResolvedValue();

const targetTimestamp = 1_000_000;
await nodeWithSequencer.setNextBlockTimestamp(targetTimestamp);

expect(mockEthCheatCodes.setNextBlockTimestamp).toHaveBeenCalledWith(targetTimestamp);
const targetMs = targetTimestamp * 1000;
expect(testDateProvider.now()).toBeGreaterThanOrEqual(targetMs);
});
});

describe('advanceNextBlockTimestampBy', () => {
it('advances timestamp by duration from current L1 timestamp', async () => {
mockEthCheatCodes.lastBlockTimestamp.mockResolvedValue(500);
mockEthCheatCodes.setNextBlockTimestamp.mockResolvedValue();

await nodeWithSequencer.advanceNextBlockTimestampBy(100);

expect(mockEthCheatCodes.setNextBlockTimestamp).toHaveBeenCalledWith(600);
expect(testDateProvider.now()).toBeGreaterThanOrEqual(600_000);
});
});

describe('updateDateProviderTimestampTo', () => {
it('throws when dateProvider does not support setTime', async () => {
const nodeWithPlainDateProvider = new AztecNodeService(
nodeConfig,
p2p,
l2BlockSource,
mock(),
mock(),
mock(),
worldState,
sequencerClient,
undefined,
undefined,
undefined,
undefined,
12345,
rollupVersion.toNumber(),
globalVariablesBuilder,
epochCache,
getPackageVersion() ?? '',
new TestCircuitVerifier(),
new TestCircuitVerifier(),
new DateProvider(),
);
(nodeWithPlainDateProvider as any).ethCheatCodes = mockEthCheatCodes;
mockEthCheatCodes.setNextBlockTimestamp.mockResolvedValue();

await expect(nodeWithPlainDateProvider.setNextBlockTimestamp(1000)).rejects.toThrow(
'Date provider does not support direct time manipulation',
);
});
});
});

describe('getL2ToL1Messages', () => {
const makeCheckpointedBlock = (slotNumber: number, l2ToL1MsgsByTx: Fr[][]): CheckpointedL2Block => {
const block = L2Block.empty(
Expand Down
102 changes: 101 additions & 1 deletion yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { getPublicClient, makeL1HttpTransport } from '@aztec/ethereum/client';
import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts';
import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses';
import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils';
import { EthCheatCodes } from '@aztec/ethereum/test';
import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { chunkBy, compactArray, pick, unique } from '@aztec/foundation/collection';
import { Fr } from '@aztec/foundation/curves/bn254';
import { EthAddress } from '@aztec/foundation/eth-address';
import { BadRequestError } from '@aztec/foundation/json-rpc';
import { type Logger, createLogger } from '@aztec/foundation/log';
import { retryUntil } from '@aztec/foundation/retry';
import { count } from '@aztec/foundation/string';
import { DateProvider, Timer } from '@aztec/foundation/timer';
import { MembershipWitness, SiblingPath } from '@aztec/foundation/trees';
Expand Down Expand Up @@ -59,13 +61,15 @@ import type {
NodeInfo,
ProtocolContractAddresses,
} from '@aztec/stdlib/contract';
import { getSlotAtTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
import { GasFees } from '@aztec/stdlib/gas';
import { computePublicDataTreeLeafSlot } from '@aztec/stdlib/hash';
import {
type AztecNode,
type AztecNodeAdmin,
type AztecNodeAdminConfig,
AztecNodeAdminConfigSchema,
type AztecNodeDebug,
type GetContractClassLogsResponse,
type GetPublicLogsResponse,
} from '@aztec/stdlib/interfaces/client';
Expand Down Expand Up @@ -126,9 +130,10 @@ import { NodeMetrics } from './node_metrics.js';
/**
* The aztec node.
*/
export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDebug, Traceable {
private metrics: NodeMetrics;
private initialHeaderHashPromise: Promise<BlockHash> | undefined = undefined;
private ethCheatCodes: EthCheatCodes | undefined;

// Prevent two snapshot operations to happen simultaneously
private isUploadingSnapshot = false;
Expand All @@ -155,6 +160,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
protected readonly packageVersion: string,
private peerProofVerifier: ClientProtocolCircuitVerifier,
private rpcProofVerifier: ClientProtocolCircuitVerifier,
private dateProvider: DateProvider,
private telemetry: TelemetryClient = getTelemetryClient(),
private log = createLogger('node'),
private blobClient?: BlobClientInterface,
Expand Down Expand Up @@ -616,6 +622,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
packageVersion,
peerProofVerifier,
rpcProofVerifier,
dateProvider,
telemetry,
log,
blobClient,
Expand Down Expand Up @@ -1643,6 +1650,99 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
this.log.info('Keystore reloaded: coinbase, feeRecipient, and attester keys updated');
}

#getEthCheatCodes(): EthCheatCodes {
if (!this.ethCheatCodes) {
this.ethCheatCodes = new EthCheatCodes(this.config.l1RpcUrls, this.dateProvider);
}
return this.ethCheatCodes;
}

/** Updates the date provider to match the given timestamp, if it supports time manipulation. */
#updateDateProviderTimestampTo(timestampInSeconds: number): void {
if (!('setTime' in this.dateProvider)) {
throw new Error('Date provider does not support direct time manipulation.');
}

(this.dateProvider as { setTime(ms: number): void }).setTime(timestampInSeconds * 1000);
}

public async setNextBlockTimestamp(timestamp: number): Promise<void> {
const ethCheatCodes = this.#getEthCheatCodes();
await ethCheatCodes.setNextBlockTimestamp(timestamp);
this.#updateDateProviderTimestampTo(timestamp);
}

public async advanceNextBlockTimestampBy(duration: number): Promise<void> {
const ethCheatCodes = this.#getEthCheatCodes();
const currentL1Timestamp = await ethCheatCodes.lastBlockTimestamp();
await ethCheatCodes.setNextBlockTimestamp(currentL1Timestamp + duration);
this.#updateDateProviderTimestampTo(currentL1Timestamp + duration);
}

public async mineBlock(): Promise<void> {
if (!this.sequencer) {
throw new BadRequestError('Cannot mine block: no sequencer is running');
}

const currentBlockNumber = await this.getBlockNumber();

// Mine one L1 block — this uses any pending evm_setNextBlockTimestamp
const ethCheatCodes = this.#getEthCheatCodes();
await ethCheatCodes.evmMine();

// Check if we're in a new L2 slot. If not, warp to the next slot's timestamp.
const l1Constants = await this.blockSource.getL1Constants();
const currentL1Timestamp = BigInt(await ethCheatCodes.lastBlockTimestamp());
const currentSlot = getSlotAtTimestamp(currentL1Timestamp, l1Constants);

const latestBlock = await this.getBlock('latest');
const lastBlockSlot = latestBlock ? latestBlock.header.globalVariables.slotNumber : SlotNumber(0);

if (currentSlot < lastBlockSlot) {
// Both Anvil and Hardhat enforce that evm_setNextBlockTimestamp only accepts timestamps strictly greater than
// the current block's timestamp, so L1 time cannot go backwards. If the current slot is behind the last block's
// slot, something has gone wrong.
throw new Error(
`Current slot ${currentSlot} is behind the last block's slot ${lastBlockSlot}. ` +
`L1 time cannot be warped backwards.`,
);
}

if (currentSlot === lastBlockSlot) {
// A block was already produced in this slot. Warp L1 time forward to the next slot so we can mine another block.
const nextSlotTimestamp = getTimestampForSlot(SlotNumber(Number(lastBlockSlot) + 1), l1Constants);
this.log.info(`Current slot ${currentSlot} already has a block, warping L1 time to slot ${lastBlockSlot + 1}`);
// warp mines a block - hence we don't need to do it manually here
await ethCheatCodes.warp(Number(nextSlotTimestamp));
}

// Update dateProvider to match L1 time
const newTimestamp = await ethCheatCodes.lastBlockTimestamp();
this.#updateDateProviderTimestampTo(newTimestamp);

// Temporarily set minTxsPerBlock to 0 so the sequencer produces a block even with no txs
const originalMinTxsPerBlock = this.sequencer.getSequencer().getConfig().minTxsPerBlock;
this.sequencer.updateConfig({ minTxsPerBlock: 0 });

try {
// Trigger the sequencer to produce a block immediately
void this.sequencer.trigger();

// Wait for the new L2 block to appear
await retryUntil(
async () => {
const newBlockNumber = await this.getBlockNumber();
return newBlockNumber > currentBlockNumber ? true : undefined;
},
'mineBlock',
30,
0.1,
);
} finally {
this.sequencer.updateConfig({ minTxsPerBlock: originalMinTxsPerBlock });
}
}

#getInitialHeaderHash(): Promise<BlockHash> {
if (!this.initialHeaderHashPromise) {
this.initialHeaderHashPromise = this.worldStateSynchronizer.getCommitted().getInitialHeader().hash();
Expand Down
3 changes: 2 additions & 1 deletion yarn-project/aztec/src/cli/aztec_start_action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@aztec/foundation/json-rpc/server';
import type { LogFn, Logger } from '@aztec/foundation/log';
import type { ChainConfig } from '@aztec/stdlib/config';
import { AztecNodeAdminApiSchema, AztecNodeApiSchema } from '@aztec/stdlib/interfaces/client';
import { AztecNodeAdminApiSchema, AztecNodeApiSchema, AztecNodeDebugApiSchema } from '@aztec/stdlib/interfaces/client';
import { getPackageVersion } from '@aztec/stdlib/update-checker';
import { getVersioningMiddleware } from '@aztec/stdlib/versioning';
import { getOtelJsonRpcPropagationMiddleware } from '@aztec/telemetry-client';
Expand Down Expand Up @@ -51,6 +51,7 @@ export async function aztecStart(options: any, userLog: LogFn, debugLogger: Logg
signalHandlers.push(stop);
services.node = [node, AztecNodeApiSchema];
adminServices.node = [node, AztecNodeAdminApiSchema];
adminServices.nodeDebug = [node, AztecNodeDebugApiSchema];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is quite foreign to me so I asked this to AI:

Image

so this seems reasonable.

If the debug interface got accidentally exposed in prod in its current form then there would not be much damage to do as the function would error out with the warping methods but anyway there is a danger of us adding more powerful methods on the interface, then accidentally exposing this in prod and then bad things happening.

Do you think we should somehow guardrail this more such that via some code changes we don't accidentally expose this?

} else {
// Route --prover-node through startNode
if (options.proverNode && !options.node) {
Expand Down
Loading
Loading