Skip to content
Draft
20 changes: 20 additions & 0 deletions yarn-project/archiver/src/archiver-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,26 @@ describe('Archiver Sync', () => {
).toEqual([1, 2, 3]);
}, 30_000);

it('publishes an atomic L1 sync snapshot consistent with the individual getters', async () => {
// No snapshot is published before the first successful sync.
expect(archiver.getL1SyncSnapshot()).toBeUndefined();

await fake.addCheckpoint(CheckpointNumber(1), {
l1BlockNumber: 101n,
messagesL1BlockNumber: 98n,
numL1ToL2Messages: 3,
});
fake.setL1BlockNumber(2500n);
await archiver.syncImmediate();

const snapshot = archiver.getL1SyncSnapshot();
expect(snapshot).toBeDefined();
// The atomic triple matches the individual getters assigned at the same point in the sync loop.
expect(snapshot!.blockNumber).toBe(archiver.getL1BlockNumber());
expect(snapshot!.blockTimestamp).toBe(await archiver.getL1Timestamp());
expect(snapshot!.blockNumber).toBe(2500n);
}, 30_000);

it('ignores checkpoint 3 because it has been pruned', async () => {
const loggerSpy = jest.spyOn(syncLogger, 'warn');

Expand Down
7 changes: 6 additions & 1 deletion yarn-project/archiver/src/archiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { BlobClientInterface } from '@aztec/blob-client/client';
import { EpochCache } from '@aztec/epoch-cache';
import { BlockTagTooOldError, OutboxContract, RollupContract } from '@aztec/ethereum/contracts';
import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses';
import type { L1SyncSnapshot, L1SyncSnapshotProvider } from '@aztec/ethereum/l1-types';
import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { Buffer32 } from '@aztec/foundation/buffer';
Expand Down Expand Up @@ -89,7 +90,7 @@ const noCheckpointProposalPresence: CheckpointProposalPresence = {
* Responsible for handling robust L1 polling so that other components do not need to
* concern themselves with it.
*/
export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Traceable {
export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, L1SyncSnapshotProvider, Traceable {
/** Event emitter for archiver events (L2BlockProven, L2PruneUnproven, L2PruneUncheckpointed, etc). */
public readonly events: ArchiverEmitter;

Expand Down Expand Up @@ -610,6 +611,10 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
return Promise.resolve(this.synchronizer.getL1Timestamp());
}

public getL1SyncSnapshot(): L1SyncSnapshot | undefined {
return this.synchronizer.getL1SyncSnapshot();
}

public async getSyncedL2SlotNumber(): Promise<SlotNumber | undefined> {
// 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
Expand Down
17 changes: 16 additions & 1 deletion yarn-project/archiver/src/modules/l1_synchronizer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BlobClientInterface } from '@aztec/blob-client/client';
import { EpochCache } from '@aztec/epoch-cache';
import { InboxContract, type InboxContractState, RollupContract } from '@aztec/ethereum/contracts';
import type { L1BlockId } from '@aztec/ethereum/l1-types';
import type { L1BlockId, L1SyncSnapshot } from '@aztec/ethereum/l1-types';
import { getFinalizedL1Block } from '@aztec/ethereum/queries';
import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
import { asyncPool } from '@aztec/foundation/async-pool';
Expand Down Expand Up @@ -74,6 +74,11 @@ export class ArchiverL1Synchronizer implements Traceable {
private l1BlockNumber: bigint | undefined;
private l1BlockHash: Buffer32 | undefined;
private l1Timestamp: bigint | undefined;
/**
* Atomic snapshot of the last fully-synced L1 identity. Published as a single object reference at the
* same point the three fields above are assigned, so a synchronous reader never observes a torn triple.
*/
private l1SyncSnapshot: L1SyncSnapshot | undefined;

private readonly updater: ArchiverDataStoreUpdater;
public readonly tracer: Tracer;
Expand Down Expand Up @@ -129,6 +134,11 @@ export class ArchiverL1Synchronizer implements Traceable {
return this.l1Timestamp;
}

/** Returns the last fully-synced L1 identity as an atomic in-memory snapshot, or undefined before the first sync. */
public getL1SyncSnapshot(): L1SyncSnapshot | undefined {
return this.l1SyncSnapshot;
}

private getSignatureContext(): CoordinationSignatureContext {
return {
chainId: this.publicClient.chain.id,
Expand Down Expand Up @@ -240,6 +250,11 @@ export class ArchiverL1Synchronizer implements Traceable {
this.l1Timestamp = currentL1Timestamp;
this.l1BlockNumber = currentL1BlockNumber;
this.l1BlockHash = currentL1BlockHash;
this.l1SyncSnapshot = {
blockNumber: currentL1BlockNumber,
blockHash: currentL1BlockHash,
blockTimestamp: currentL1Timestamp,
};

const l1BlockNumberAtEnd = await this.publicClient.getBlockNumber();
this.log.debug(`Archiver sync iteration complete`, {
Expand Down
3 changes: 3 additions & 0 deletions yarn-project/archiver/src/test/noop_l1_archiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class NoopL1Synchronizer implements FunctionsOf<ArchiverL1Synchronizer> {
getL1Timestamp(): bigint | undefined {
return undefined;
}
getL1SyncSnapshot(): undefined {
return undefined;
}
testEthereumNodeSynced(): Promise<void> {
return Promise.resolve();
}
Expand Down
16 changes: 15 additions & 1 deletion yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { uploadSnapshot } from '@aztec/node-lib/actions';
import { type P2P, createTxValidatorForAcceptingTxsOverRPC, getDefaultAllowedSetupFunctions } from '@aztec/p2p';
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
import type { ProverNode } from '@aztec/prover-node';
import { SequencerClient } from '@aztec/sequencer-client';
import { type FeeSnapshotStats, SequencerClient } from '@aztec/sequencer-client';
import { AutomineSequencer } from '@aztec/sequencer-client/automine';
import type { SlasherClientInterface } from '@aztec/slasher';
import { STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS } from '@aztec/standard-contracts/multi-call-entrypoint';
Expand Down Expand Up @@ -138,6 +138,8 @@ export interface AztecNodeServiceDeps {
globalVariableBuilder: GlobalVariableBuilderInterface;
rollupContract: RollupContract | undefined;
feeProvider: FeeProvider;
/** Background fee snapshot service, stopped before the archiver so its final refresh sees a live identity. */
feeSnapshotService?: { stop(): Promise<void>; getStats(): FeeSnapshotStats };
epochCache: EpochCacheInterface;
packageVersion: string;
peerProofVerifier: ClientProtocolCircuitVerifier;
Expand Down Expand Up @@ -184,6 +186,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
protected readonly globalVariableBuilder: GlobalVariableBuilderInterface;
protected readonly rollupContract: RollupContract | undefined;
protected readonly feeProvider: FeeProvider;
private readonly feeSnapshotService?: { stop(): Promise<void>; getStats(): FeeSnapshotStats };
protected readonly epochCache: EpochCacheInterface;
protected readonly packageVersion: string;
private peerProofVerifier: ClientProtocolCircuitVerifier;
Expand Down Expand Up @@ -214,6 +217,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
this.globalVariableBuilder = deps.globalVariableBuilder;
this.rollupContract = deps.rollupContract;
this.feeProvider = deps.feeProvider;
this.feeSnapshotService = deps.feeSnapshotService;
this.epochCache = deps.epochCache;
this.packageVersion = deps.packageVersion;
this.peerProofVerifier = deps.peerProofVerifier;
Expand Down Expand Up @@ -567,6 +571,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
await tryStop(this.proverNode);
await tryStop(this.p2pClient);
await tryStop(this.worldStateSynchronizer);
// Stop the fee snapshot service before the archiver so its identity provider is still live during shutdown.
await tryStop(this.feeSnapshotService);
await tryStop(this.blockSource);
await tryStop(this.blobClient);
await tryStop(this.telemetry);
Expand All @@ -581,6 +587,14 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
return this.blobClient;
}

/**
* Returns the background fee snapshot service counters, or undefined if the service is not wired.
* @internal - Exposed for benchmarking/testing purposes only.
*/
public getFeeSnapshotStats(): FeeSnapshotStats | undefined {
return this.feeSnapshotService?.getStats();
}

/**
* Method to retrieve pending txs.
* @param limit - The number of items to returns
Expand Down
22 changes: 21 additions & 1 deletion yarn-project/aztec-node/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import { type ProverNode, type ProverNodeDeps, createProverNode } from '@aztec/p
import { createKeyStoreForProver } from '@aztec/prover-node/config';
import {
FeeProviderImpl,
FeeSnapshotService,
GlobalVariableBuilder,
SequencerClient,
type SequencerPublisher,
getDefaultFeeSnapshotServiceConfig,
} from '@aztec/sequencer-client';
import { type AutomineSequencer, createAutomineSequencer } from '@aztec/sequencer-client/automine';
import {
Expand Down Expand Up @@ -241,7 +243,24 @@ export async function createAztecNodeService(
};

const globalVariableBuilder = new GlobalVariableBuilder(publicClient, globalVariableBuilderConfig);
const feeProvider = new FeeProviderImpl(dateProvider, publicClient, globalVariableBuilderConfig);

// Serve fee RPCs (and the p2p mempool fee policy) from a background snapshot refreshed per L1 block, so
// warm calls issue zero L1 requests. The service pins its reads to the archiver's synced L1 identity.
const feeSnapshotService = new FeeSnapshotService(
rollupContract,
archiver,
dateProvider,
getDefaultFeeSnapshotServiceConfig({
slotDuration: Number(slotDuration),
l1GenesisTime,
ethereumSlotDuration: config.ethereumSlotDuration,
epochDuration: Number(epochDuration),
}),
log.createChild('fee-snapshot'),
);
feeSnapshotService.start();
started.push(feeSnapshotService);
const feeProvider = new FeeProviderImpl(feeSnapshotService);

const collectOffenses = !config.disableValidator || config.enableOffenseCollection;

Expand Down Expand Up @@ -623,6 +642,7 @@ export async function createAztecNodeService(
globalVariableBuilder,
rollupContract,
feeProvider,
feeSnapshotService,
epochCache,
packageVersion,
peerProofVerifier,
Expand Down
111 changes: 111 additions & 0 deletions yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* 2. Builds a few blocks by sending transactions
* 3. Benchmarks all node RPC API methods
*/
import type { AztecNodeService } from '@aztec/aztec-node';
import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses';
import { Fr } from '@aztec/aztec.js/fields';
import type { Logger } from '@aztec/aztec.js/log';
Expand All @@ -15,6 +16,7 @@ import { BlockNumber, EpochNumber } from '@aztec/foundation/branded-types';
import { Timer } from '@aztec/foundation/timer';
import { TokenContract } from '@aztec/noir-contracts.js/Token';
import { BlockHash } from '@aztec/stdlib/block';
import { ManaUsageEstimate } from '@aztec/stdlib/gas';
import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client';
import { SiloedTag, Tag } from '@aztec/stdlib/logs';
import { MerkleTreeId } from '@aztec/stdlib/trees';
Expand All @@ -30,6 +32,15 @@ import { setup } from '../fixtures/utils.js';
import type { TestWallet } from '../test-wallet/test_wallet.js';
import { proveInteraction } from '../test-wallet/utils.js';

function percentile(values: number[], p: number): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
return sorted[index];
}

/** Number of iterations for fast RPC calls */
const BENCHMARK_ITERATIONS_FAST = 20;

Expand Down Expand Up @@ -103,6 +114,7 @@ describe('e2e_node_rpc_perf', () => {

let logger: Logger;
let aztecNode: AztecNode & AztecNodeDebug;
let aztecNodeService: AztecNodeService;
let wallet: TestWallet;
let ownerAddress: AztecAddress;
let rollupCheatCodes: RollupCheatCodes;
Expand Down Expand Up @@ -145,6 +157,7 @@ describe('e2e_node_rpc_perf', () => {
teardown,
logger,
aztecNode,
aztecNodeService,
wallet,
accounts: [ownerAddress],
cheatCodes: { rollup: rollupCheatCodes },
Expand Down Expand Up @@ -563,6 +576,104 @@ describe('e2e_node_rpc_perf', () => {
});
});

// The zero-fee-path-L1 property is gated directly on the FeeSnapshotService counter (readTriggeredRefreshes):
// a warm read that is served from the in-memory snapshot triggers no refresh and issues no L1 request. This
// is authoritative regardless of L1 latency, so it does not require routing the node through the latency proxy
// (which would conflict with the shared setup's ownership of the Anvil clock/mining). A same-run local-RPC
// baseline (getBlockNumber) bounds the warm fee-path latency.
describe('warm fee APIs served from the snapshot', () => {
async function measureWarm(fn: () => Promise<unknown>, iterations: number): Promise<number[]> {
const timings: number[] = [];
for (let i = 0; i < iterations; i++) {
const timer = new Timer();
await fn();
timings.push(timer.ms());
}
return timings;
}

it('serves warm getPredictedMinFees with zero fee-path L1 requests, near a local baseline (sequential)', async () => {
// Warm the snapshot first so the measured calls are served from memory.
await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit);

const baseline = await measureWarm(() => aztecNode.getBlockNumber(), BENCHMARK_ITERATIONS_FAST);
const statsBefore = aztecNodeService.getFeeSnapshotStats();
const feeTimings = await measureWarm(
() => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit),
BENCHMARK_ITERATIONS_FAST,
);
const statsAfter = aztecNodeService.getFeeSnapshotStats();

const baselineP95 = percentile(baseline, 95);
const feeP95 = percentile(feeTimings, 95);
addResult('getPredictedMinFees_warm_seq_p95', calculateStats(feeTimings));
logger.info('Warm getPredictedMinFees (sequential)', {
feeP95,
baselineP95,
readTriggeredRefreshesDelta:
(statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0),
});

// Gated: warm reads trigger no refresh (zero fee-path L1 requests) and stay within tolerance of the baseline.
if (statsBefore && statsAfter) {
expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes);
}
expect(feeP95).toBeLessThan(baselineP95 + 100);
});

it('serves warm getPredictedMinFees with zero fee-path L1 requests (concurrent)', async () => {
await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit);

const statsBefore = aztecNodeService.getFeeSnapshotStats();
const timer = new Timer();
await Promise.all(
Array.from({ length: BENCHMARK_ITERATIONS_FAST }, () => aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit)),
);
const totalMs = timer.ms();
const statsAfter = aztecNodeService.getFeeSnapshotStats();

logger.info('Warm getPredictedMinFees (20 concurrent)', {
totalMs,
readTriggeredRefreshesDelta:
(statsAfter?.readTriggeredRefreshes ?? 0) - (statsBefore?.readTriggeredRefreshes ?? 0),
});
addResult('getPredictedMinFees_warm_concurrent_total', {
avg: totalMs,
min: totalMs,
max: totalMs,
total: totalMs,
count: BENCHMARK_ITERATIONS_FAST,
});

// Gated: 20 concurrent warm reads share the snapshot and trigger no refresh.
if (statsBefore && statsAfter) {
expect(statsAfter.readTriggeredRefreshes).toBe(statsBefore.readTriggeredRefreshes);
}
expect(totalMs).toBeLessThan(1000);
});

it('reports the first getPredictedMinFees after advancing the chain (non-gating)', async () => {
const before = aztecNodeService.getFeeSnapshotStats();
// Advance the chain to force an archiver identity change and observe the boundary refresh cost.
await rollupCheatCodes.advanceSlots(1);
const timer = new Timer();
await aztecNode.getPredictedMinFees(ManaUsageEstimate.Limit);
const firstCallMs = timer.ms();
const after = aztecNodeService.getFeeSnapshotStats();
logger.info('First getPredictedMinFees after advancing the chain (reported)', {
firstCallMs,
refreshesDelta: (after?.refreshes ?? 0) - (before?.refreshes ?? 0),
});
addResult('getPredictedMinFees_boundary_first_call', {
avg: firstCallMs,
min: firstCallMs,
max: firstCallMs,
total: firstCallMs,
count: 1,
});
});
});

describe('log APIs', () => {
it('benchmarks getPrivateLogsByTags', async () => {
const tags = [SiloedTag.random()];
Expand Down
Loading