Skip to content
Merged
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
13 changes: 4 additions & 9 deletions yarn-project/archiver/src/archiver-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2555,7 +2555,7 @@ describe('Archiver Sync', () => {
archiver.events.off(L2BlockSourceEvents.L2BlockSourceUpdated, updateSpy);
});

it('emits a single aggregate event carrying fromTips, toTips and blocksAdded for a checkpoint sync', async () => {
it('emits a single aggregate event carrying fromTips and toTips for a checkpoint sync', async () => {
const { checkpoint: cp1 } = await fake.addCheckpoint(CheckpointNumber(1), {
l1BlockNumber: 70n,
messagesL1BlockNumber: 60n,
Expand All @@ -2572,7 +2572,6 @@ describe('Archiver Sync', () => {
expect(event.fromTips.proposed.number).toEqual(BlockNumber(0));
expect(event.toTips.proposed.number).toEqual(lastBlock);
expect(event.toTips.checkpointed.checkpoint.number).toEqual(CheckpointNumber(1));
expect(event.blocksAdded.map(b => b.number)).toEqual(cp1.blocks.map(b => b.number));
});

it('emits no aggregate event on a fully-synced no-op pass', async () => {
Expand Down Expand Up @@ -2613,18 +2612,15 @@ describe('Archiver Sync', () => {
updateSpy.mockClear();
await archiver.syncImmediate();

// The conflicting local blocks are pruned and replaced by the L1 chain. The aggregate event carries the
// newly-fetched L1 blocks (the prune itself is reflected by the moved tips, not by the delta).
// The conflicting local blocks are pruned and replaced by the L1 chain, which the single aggregate event
// reports through its moved tips.
expect(updateSpy).toHaveBeenCalledTimes(1);
const event = updateSpy.mock.calls[0][0] as L2BlockSourceUpdatedEvent;
expect(event.blocksAdded.map(b => b.number)).toEqual(
expect.arrayContaining(differentCp2.blocks.map(b => b.number)),
);
expect(event.toTips.checkpointed.checkpoint.number).toEqual(CheckpointNumber(2));
expect(event.toTips.proposed.number).toEqual(differentCp2.blocks.at(-1)!.number);
}, 15_000);

it('emits an aggregate event with no blocks added when only the proven tip advances', async () => {
it('emits an aggregate event when only the proven tip advances', async () => {
const { checkpoint: cp1 } = await fake.addCheckpoint(CheckpointNumber(1), { l1BlockNumber: 70n });
fake.setL1BlockNumber(100n);
await archiver.syncImmediate();
Expand All @@ -2640,7 +2636,6 @@ describe('Archiver Sync', () => {
expect(updateSpy).toHaveBeenCalledTimes(1);
const event = updateSpy.mock.calls[0][0] as L2BlockSourceUpdatedEvent;
// No blocks were added; the event still fires because the proven tip moved (proposed/checkpointed unchanged).
expect(event.blocksAdded).toEqual([]);
expect(event.fromTips.proven.checkpoint.number).toEqual(CheckpointNumber(0));
expect(event.toTips.proven.checkpoint.number).toEqual(CheckpointNumber(1));
expect(event.fromTips.proposed.number).toEqual(event.toTips.proposed.number);
Expand Down
1 change: 0 additions & 1 deletion yarn-project/archiver/src/archiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,6 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
type: L2BlockSourceEvents.L2BlockSourceUpdated,
fromTips,
toTips,
blocksAdded,
});
}
}
Expand Down
7 changes: 5 additions & 2 deletions yarn-project/archiver/src/modules/data_store_updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,14 @@ export class ArchiverDataStoreUpdater {
},
evictProposedFrom?: CheckpointNumber,
): Promise<ReconcileCheckpointsResult> {
// These checkpoints are already on L1, so ingest tolerates an empty non-first block. The rule is enforced
// by proposers and attesters; rejecting an ingest would stall sync rather than undo the checkpoint.
const validateOpts = { rollupManaLimit: this.opts?.rollupManaLimit, allowEmptyNonFirstBlocks: true };
for (const checkpoint of checkpoints) {
validateCheckpoint(checkpoint.checkpoint, { rollupManaLimit: this.opts?.rollupManaLimit });
validateCheckpoint(checkpoint.checkpoint, validateOpts);
}
if (promoteProposed) {
validateCheckpoint(promoteProposed.checkpoint.checkpoint, { rollupManaLimit: this.opts?.rollupManaLimit });
validateCheckpoint(promoteProposed.checkpoint.checkpoint, validateOpts);
}

const result = await this.stores.db.transactionAsync(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@aztec/foundation/json-rpc/server';
import type { L2Tips } from '@aztec/stdlib/block';
import { AztecNodeAdminApiSchema, AztecNodeApiSchema, AztecNodeDebugApiSchema } from '@aztec/stdlib/interfaces/client';
import { P2PApiSchema } from '@aztec/stdlib/interfaces/server';
import { P2PApiSchema, type P2PConnectivity } from '@aztec/stdlib/interfaces/server';
import type { ApiSchemaFor } from '@aztec/stdlib/schemas';

import { registerAztecNodeRpcHandlers } from './register_node_rpc_handlers.js';
Expand All @@ -20,7 +20,9 @@ const GetChainTipsOnlySchema: ApiSchemaFor<GetChainTipsOnly> = {
getChainTips: AztecNodeApiSchema.getChainTips,
};

const p2p = {};
let connectivity: P2PConnectivity;

const p2p = { getP2PConnectivity: () => Promise.resolve(connectivity) };

const mockNode = {
getP2P: () => p2p,
Expand All @@ -47,7 +49,8 @@ describe('registerAztecNodeRpcHandlers', () => {

expect(services.aztec).toEqual([mockNode, AztecNodeApiSchema]);
expect(services.node).toBe(services.aztec);
expect(services.p2p).toEqual([p2p, P2PApiSchema]);
expect(services.p2p[0]).toBe(p2p);
expect(services.p2p[1]).toBe(P2PApiSchema);
expect(services.aztecDebug).toEqual([mockNode, AztecNodeDebugApiSchema]);
expect(services.nodeDebug).toBe(services.aztecDebug);
expect(adminServices.aztecAdmin).toEqual([mockNode, AztecNodeAdminApiSchema]);
Expand All @@ -63,6 +66,52 @@ describe('registerAztecNodeRpcHandlers', () => {
expect(services.nodeDebug).toBeUndefined();
});

describe('p2p health check', () => {
const getP2PHealthCheck = (p2pHealthMinPeers?: number) => {
const services: NamespacedApiHandlers = {};
registerAztecNodeRpcHandlers(mockNode, services, undefined, { p2pHealthMinPeers });
const healthCheck = services.p2p[2];
expect(healthCheck).toBeDefined();
return healthCheck!;
};

it('reports connectivity and stays healthy with no peers by default', async () => {
connectivity = { enabled: true, connectedPeers: 0 };

await expect(getP2PHealthCheck()()).resolves.toEqual({
healthy: true,
details: { enabled: true, connectedPeers: 0 },
});
});

it('is unhealthy with fewer peers than the configured minimum', async () => {
connectivity = { enabled: true, connectedPeers: 0 };

await expect(getP2PHealthCheck(1)()).resolves.toEqual({
healthy: false,
details: { enabled: true, connectedPeers: 0 },
});
});

it('is healthy with at least the configured minimum of peers', async () => {
connectivity = { enabled: true, connectedPeers: 3 };

await expect(getP2PHealthCheck(3)()).resolves.toEqual({
healthy: true,
details: { enabled: true, connectedPeers: 3 },
});
});

it('is healthy when p2p is disabled regardless of the configured minimum', async () => {
connectivity = { enabled: false, connectedPeers: 0 };

await expect(getP2PHealthCheck(1)()).resolves.toEqual({
healthy: true,
details: { enabled: false, connectedPeers: 0 },
});
});
});

it('serves node_* methods as aliases of aztec_*', async () => {
const services: NamespacedApiHandlers = {};
registerAztecNodeRpcHandlers(mockNode, services);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import type { NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server';
import type { NamespacedApiHandlers, StatusCheckFn } from '@aztec/foundation/json-rpc/server';
import { AztecNodeAdminApiSchema, AztecNodeApiSchema, AztecNodeDebugApiSchema } from '@aztec/stdlib/interfaces/client';
import { P2PApiSchema } from '@aztec/stdlib/interfaces/server';
import { type P2PApi, P2PApiSchema } from '@aztec/stdlib/interfaces/server';

import type { AztecNodeService } from './server.js';

/**
* Health check for the p2p component: reports whether p2p is enabled and how many peers are connected, and fails
* when p2p is enabled but connected to fewer peers than the given minimum. A minimum of zero never fails.
*/
function makeP2PHealthCheck(p2p: P2PApi, minPeers: number): StatusCheckFn {
return async () => {
const { enabled, connectedPeers } = await p2p.getP2PConnectivity();
return { healthy: !enabled || connectedPeers >= minPeers, details: { enabled, connectedPeers } };
};
}

/**
* Registers the Aztec node RPC handlers (`aztec_*`, `aztecAdmin_*`, and optionally `aztecDebug_*`), along with the
* legacy pre-v5 namespaces (`node_*`, `nodeAdmin_*`, `nodeDebug_*`, `p2p_*`) for backwards compatibility.
Expand All @@ -13,11 +24,12 @@ export function registerAztecNodeRpcHandlers(
node: AztecNodeService,
services: NamespacedApiHandlers,
adminServices?: NamespacedApiHandlers,
options: { debug?: boolean } = {},
options: { debug?: boolean; p2pHealthMinPeers?: number } = {},
): void {
const p2p = node.getP2P();
services.aztec = [node, AztecNodeApiSchema];
services.node = services.aztec;
services.p2p = [node.getP2P(), P2PApiSchema];
services.p2p = [p2p, P2PApiSchema, makeP2PHealthCheck(p2p, options.p2pHealthMinPeers ?? 0)];
if (adminServices) {
adminServices.aztecAdmin = [node, AztecNodeAdminApiSchema];
adminServices.nodeAdmin = adminServices.aztecAdmin;
Expand Down
27 changes: 27 additions & 0 deletions yarn-project/aztec-node/src/aztec-node/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ describe('aztec node', () => {
const feePayerBalance = 10n ** 20n;

p2p = mock<P2P>();
p2p.getP2PConnectivity.mockResolvedValue({ enabled: false, connectedPeers: 0 });

globalVariablesBuilder = mock<GlobalVariableBuilder>();
feeProvider = mock<FeeProvider>();
Expand Down Expand Up @@ -395,6 +396,32 @@ describe('aztec node', () => {
});
});

describe('sendTx', () => {
it('rejects the tx when p2p is enabled but has no connected peers', async () => {
p2p.getP2PConnectivity.mockResolvedValue({ enabled: true, connectedPeers: 0 });
const tx = await mockTxForRollup(0x10000);

await expect(node.sendTx(tx)).rejects.toThrow('no connected peers');
expect(p2p.sendTx).not.toHaveBeenCalled();
});

it('accepts the tx when p2p is enabled and has connected peers', async () => {
p2p.getP2PConnectivity.mockResolvedValue({ enabled: true, connectedPeers: 1 });
const tx = await mockTxForRollup(0x10000);

await node.sendTx(tx);
expect(p2p.sendTx).toHaveBeenCalledWith(tx);
});

it('accepts the tx when p2p is disabled', async () => {
p2p.getP2PConnectivity.mockResolvedValue({ enabled: false, connectedPeers: 0 });
const tx = await mockTxForRollup(0x10000);

await node.sendTx(tx);
expect(p2p.sendTx).toHaveBeenCalledWith(tx);
});
});

describe('getters', () => {
describe('config', () => {
it('returns the correct config', async () => {
Expand Down
7 changes: 7 additions & 0 deletions yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,13 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
const timer = new Timer();
const txHash = tx.getTxHash().toString();

const connectivity = await this.p2pClient.getP2PConnectivity();
if (connectivity.enabled && connectivity.connectedPeers === 0) {
this.metrics.receivedTx(timer.ms(), false);
this.log.warn(`Rejecting tx ${txHash}: node has no connected peers`, { txHash });
throw new Error('Cannot accept tx: node has no connected peers to propagate it');
}

const valid = await this.isValidTx(tx);
if (valid.result !== 'valid') {
const reason = valid.reason.join(', ');
Expand Down
4 changes: 3 additions & 1 deletion yarn-project/aztec-node/src/bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ async function main() {
process.once('SIGTERM', shutdown);

const services: NamespacedApiHandlers = {};
registerAztecNodeRpcHandlers(aztecNode, services);
registerAztecNodeRpcHandlers(aztecNode, services, undefined, {
p2pHealthMinPeers: aztecNodeConfig.p2pHealthMinPeers,
});
const rpcServer = createNamespacedSafeJsonRpcServer(services, {
diagnostic: getOtelJsonRpcDiagnosticsMiddleware(),
middlewares: [getOtelJsonRpcServerMetricsMiddleware(), getOtelJsonRpcPropagationMiddleware()],
Expand Down
26 changes: 16 additions & 10 deletions yarn-project/aztec-node/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,16 +402,22 @@ export async function createAztecNodeService(
}

if (collectOffenses) {
dataWithholdingWatcher = new DataWithholdingWatcher(
epochCache,
archiver,
p2pClient.getTxProvider(),
p2pClient,
reexecutionTracker,
{ chainId: config.l1ChainId, rollupAddress: config.rollupAddress },
config,
);
watchers.push(dataWithholdingWatcher);
// The watcher's only evidence is the absence of gossiped txs in the local pool, so it cannot make a
// valid claim on a node that runs no p2p stack at all. Skipped entirely rather than gated at runtime.
if (config.p2pEnabled) {
dataWithholdingWatcher = new DataWithholdingWatcher(
epochCache,
archiver,
p2pClient.getTxProvider(),
p2pClient,
reexecutionTracker,
{ chainId: config.l1ChainId, rollupAddress: config.rollupAddress },
config,
);
watchers.push(dataWithholdingWatcher);
} else {
log.verbose('Skipping data-withholding watcher since p2p is disabled');
}

broadcastedInvalidCheckpointProposalWatcher = new BroadcastedInvalidCheckpointProposalWatcher(
p2pClient,
Expand Down
5 changes: 4 additions & 1 deletion yarn-project/aztec/src/cli/cmds/start_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,10 @@ export async function startNode(
// Create and start Aztec Node
const node = await createAztecNode(nodeConfig, { telemetry, proverBroker: broker }, { genesis });

registerAztecNodeRpcHandlers(node, services, adminServices, { debug: options.nodeDebug });
registerAztecNodeRpcHandlers(node, services, adminServices, {
debug: options.nodeDebug,
p2pHealthMinPeers: nodeConfig.p2pHealthMinPeers,
});

// Register prover-node services if the prover node subsystem is running
const proverNode = node.getProverNode();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Fr } from '@aztec/aztec.js/fields';
import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { sleep } from '@aztec/foundation/sleep';
import type { TestContract } from '@aztec/noir-test-contracts.js/Test';
import { registerPhantomGossipPeer } from '@aztec/p2p/test-helpers';
import { SequencerState } from '@aztec/sequencer-client';
import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';

Expand Down Expand Up @@ -103,6 +104,10 @@ describe('single-node/misc/missed_l1_slot', () => {
perBlockAllocationMultiplier: 8,
});

// This node is the only member of the mock gossip bus, so it would otherwise count zero connected peers:
// the node would reject the txs sent below and the proposer would skip its slots under minPeersToPropose.
await registerPhantomGossipPeer(test.context.mockGossipSubNetwork!);

from = test.context.accounts[0];
contract = await test.registerTestContract(test.context.wallet);
});
Expand Down
6 changes: 6 additions & 0 deletions yarn-project/ethereum/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type HttpTransport,
type LocalAccount,
type PrivateKeyAccount,
RpcRequestError,
createPublicClient,
createWalletClient,
fallback,
Expand Down Expand Up @@ -58,6 +59,11 @@ export function isL1RpcHttpStatus(err: unknown, status: number): boolean {
return getL1RpcHttpStatus(err) === status;
}

/** Returns the JSON-RPC error code reported by the L1 node, if the error's cause chain carries one. */
export function getL1RpcErrorCode(err: unknown): number | undefined {
return getErrorCause(err, RpcRequestError)?.code;
}

function wrapL1RpcTransport(transport: FallbackTransport<HttpTransport[]>): FallbackTransport<HttpTransport[]> {
const wrappedTransport: FallbackTransport<HttpTransport[]> = parameters => {
const fallbackTransport = transport(parameters);
Expand Down
Loading
Loading