diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index e6c2108e985d..b5ffef135e46 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -111,6 +111,7 @@ import { createBlockProposalHandler, createValidatorClient, } from '@aztec/validator-client'; +import type { SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types'; import { createWorldStateSynchronizer } from '@aztec/world-state'; import { createPublicClient } from 'viem'; @@ -195,6 +196,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { dateProvider?: DateProvider; p2pClientDeps?: P2PClientDeps; proverNodeDeps?: Partial; + slashingProtectionDb?: SlashingProtectionDatabase; } = {}, options: { prefilledPublicData?: PublicDataTreeLeaf[]; @@ -377,6 +379,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { l1ToL2MessageSource: archiver, keyStoreManager, blobClient, + slashingProtectionDb: deps.slashingProtectionDb, }); // If we have a validator client, register it as a source of offenses for the slasher, diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_ha_sync.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_ha_sync.test.ts new file mode 100644 index 000000000000..fa8b97f02252 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_ha_sync.test.ts @@ -0,0 +1,203 @@ +import type { Archiver } from '@aztec/archiver'; +import type { AztecNodeService } from '@aztec/aztec-node'; +import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; +import { NO_WAIT } from '@aztec/aztec.js/contracts'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import type { Operator } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { times, timesAsync } from '@aztec/foundation/collection'; +import { SecretValue } from '@aztec/foundation/config'; +import { retryUntil } from '@aztec/foundation/retry'; +import { bufferToHex } from '@aztec/foundation/string'; +import { TestContract } from '@aztec/noir-test-contracts.js/Test'; +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import { createSharedSlashingProtectionDb } from '@aztec/validator-ha-signer/factory'; + +import { jest } from '@jest/globals'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js'; +import { TestWallet } from '../test-wallet/test_wallet.js'; +import { proveInteraction } from '../test-wallet/utils.js'; +import { EpochsTestContext } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 20); + +const VALIDATOR_COUNT = 4; +const TX_COUNT = 6; + +/** + * E2E test for HA (High Availability) proposed chain sync. + * Verifies that nodes sharing validator keys with the proposer still process + * block proposals and sync to the proposed chain, rather than ignoring them. + */ +describe('e2e_epochs/epochs_ha_sync', () => { + let context: EndToEndContext; + let logger: Logger; + let rollup: RollupContract; + + let test: EpochsTestContext; + let validators: (Operator & { privateKey: `0x${string}` })[]; + let nodes: AztecNodeService[]; + let contract: TestContract; + let wallet: TestWallet; + let from: AztecAddress; + + async function setupTest() { + validators = times(VALIDATOR_COUNT, i => { + const privateKey = bufferToHex(getPrivateKeyFromIndex(i + 3)!); + const attester = EthAddress.fromString(privateKeyToAccount(privateKey).address); + return { attester, withdrawer: attester, privateKey, bn254SecretKey: new SecretValue(Fr.random().toBigInt()) }; + }); + + // Do NOT set skipPublishingCheckpointsPercent here: the initial sequencer needs to + // publish checkpoints during setup (account deployment). We disable it per-validator-node below. + test = await EpochsTestContext.setup({ + numberOfAccounts: 1, + initialValidators: validators, + mockGossipSubNetwork: true, + disableAnvilTestWatcher: true, + aztecEpochDuration: 4, + enforceTimeTable: true, + ethereumSlotDuration: 4, + aztecSlotDuration: 36, + blockDurationMs: 8000, + l1PublishingTime: 2, + attestationPropagationTime: 0.5, + aztecTargetCommitteeSize: VALIDATOR_COUNT, + minTxsPerBlock: 1, + maxTxsPerBlock: 2, + pxeOpts: { syncChainTip: 'proposed' }, + }); + + ({ context, logger, rollup } = test); + wallet = context.wallet; + from = context.accounts[0]; + + // Stop the initial non-validator sequencer. + logger.warn(`Stopping sequencer in initial aztec node.`); + await context.sequencer!.stop(); + + // Create 4 nodes in 2 HA pairs: each pair shares the same two validator keys. + const pk1 = validators[0].privateKey; + const pk2 = validators[1].privateKey; + const pk3 = validators[2].privateKey; + const pk4 = validators[3].privateKey; + + // Disable checkpoint publishing on validator nodes so we can assert proposed chain sync + // strictly before any checkpoint is published by the validators. + // Use different coinbase addresses per node so HA peers would build different blocks + // if the proposer's block isn't correctly propagated to its HA peer. + // Each HA pair shares a slashing protection DB so only one peer can sign per duty. + const baseOpts = { dontStartSequencer: true, skipPublishingCheckpointsPercent: 100 } as const; + const sharedDb1 = await createSharedSlashingProtectionDb(context.dateProvider); + const sharedDb2 = await createSharedSlashingProtectionDb(context.dateProvider); + + logger.warn(`Creating 4 validator nodes in 2 HA pairs.`); + nodes = [ + await test.createValidatorNode([pk1, pk2], { + ...baseOpts, + coinbase: EthAddress.fromNumber(1), + slashingProtectionDb: sharedDb1, + }), + await test.createValidatorNode([pk1, pk2], { + ...baseOpts, + coinbase: EthAddress.fromNumber(2), + slashingProtectionDb: sharedDb1, + }), + await test.createValidatorNode([pk3, pk4], { + ...baseOpts, + coinbase: EthAddress.fromNumber(3), + slashingProtectionDb: sharedDb2, + }), + await test.createValidatorNode([pk3, pk4], { + ...baseOpts, + coinbase: EthAddress.fromNumber(4), + slashingProtectionDb: sharedDb2, + }), + ]; + logger.warn(`Created 4 validator nodes.`); + + // Point the wallet at a validator node so it tracks proposed blocks. + wallet.updateNode(nodes[0]); + + // Register contract for sending txs. + contract = await test.registerTestContract(wallet); + logger.warn(`Test setup completed.`); + } + + afterEach(async () => { + jest.restoreAllMocks(); + await test?.teardown(); + }); + + it('HA peers sync to proposed chain from proposals signed by their own validator keys', async () => { + await setupTest(); + + // Record the checkpoint state after setup. Validators must produce proposed blocks + // beyond this point for the test to be meaningful. + const allArchivers = nodes.map(n => n.getBlockSource() as Archiver); + const initialCheckpointNumber = await rollup.getCheckpointNumber(); + const initialCheckpointedBlock = (await allArchivers[0].getL2Tips()).checkpointed.block.number; + logger.warn(`Initial state: checkpoint ${initialCheckpointNumber}, checkpointed block ${initialCheckpointedBlock}`); + + // Pre-prove and send transactions. + const txs = await timesAsync(TX_COUNT, i => + proveInteraction(context.wallet, contract.methods.emit_nullifier(new Fr(i + 1)), { from }), + ); + const txHashes = await Promise.all(txs.map(tx => tx.send({ wait: NO_WAIT }))); + logger.warn(`Sent ${txHashes.length} transactions.`); + + // Warp to 1 L1 slot before the start of the next L2 slot, so sequencers start cleanly. + const currentSlot = await rollup.getSlotNumber(); + const nextSlot = SlotNumber(currentSlot + 1); + const nextSlotTimestamp = getTimestampForSlot(nextSlot, test.constants); + await context.cheatCodes.eth.warp(Number(nextSlotTimestamp) - test.L1_BLOCK_TIME_IN_S, { + resetBlockInterval: true, + }); + logger.warn(`Warped to 1 L1 slot before L2 slot ${nextSlot}.`); + + // Start the sequencers on all nodes. + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + logger.warn(`Started all sequencers.`); + + // Wait until all nodes have proposed blocks strictly beyond the checkpointed tip. + // This ensures we're checking blocks produced by validators via P2P proposals, + // not blocks synced from L1 checkpoints during setup. + await retryUntil( + async () => { + const tips = await Promise.all(allArchivers.map(a => a.getL2Tips())); + return tips.every( + t => t.proposed.number > initialCheckpointedBlock && t.proposed.number > t.checkpointed.block.number, + ); + }, + 'all nodes to sync proposed blocks beyond checkpointed tip', + test.L2_SLOT_DURATION_IN_S * 5, + 0.5, + ); + + logger.warn(`All nodes synced proposed blocks beyond checkpointed tip`); + + // Take the smallest proposed tip across all nodes and verify the block hash matches on all of them. + // This block is strictly proposed (not checkpointed), so it must have arrived via P2P. + const tips = await Promise.all(allArchivers.map(a => a.getL2Tips())); + const proposedNumbers = tips.map(t => t.proposed.number); + const minProposed = BlockNumber(Math.min(...proposedNumbers)); + expect(minProposed).toBeGreaterThan(initialCheckpointedBlock); + logger.warn(`Verifying block hashes at proposed block ${minProposed}.`, { proposedNumbers }); + + const headers = await Promise.all(allArchivers.map(a => a.getBlockHeader(minProposed))); + const hashes = await Promise.all(headers.map(h => h!.hash())); + for (let i = 1; i < hashes.length; i++) { + expect(hashes[i].toString()).toBe(hashes[0].toString()); + } + logger.warn(`All 4 nodes agree on block hash at proposed block ${minProposed}.`); + + // Verify that no new checkpoints have been published by validators (we disabled checkpoint publishing). + const currentCheckpointNumber = await rollup.getCheckpointNumber(); + expect(currentCheckpointNumber).toBe(initialCheckpointNumber); + logger.warn(`Verified no new checkpoints were published.`); + }); +}); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts index 0068706853bd..1a38395c9df3 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts @@ -28,6 +28,7 @@ import { type SequencerClient, type SequencerEvents, SequencerState } from '@azt import { type BlockParameter, EthAddress } from '@aztec/stdlib/block'; import { type L1RollupConstants, getProofSubmissionDeadlineTimestamp } from '@aztec/stdlib/epoch-helpers'; import { tryStop } from '@aztec/stdlib/interfaces/server'; +import type { SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types'; import { join } from 'path'; import type { Hex } from 'viem'; @@ -238,13 +239,21 @@ export class EpochsTestContext { public createValidatorNode( privateKeys: `0x${string}`[], - opts: Partial & { dontStartSequencer?: boolean } = {}, + opts: Partial & { + dontStartSequencer?: boolean; + slashingProtectionDb?: SlashingProtectionDatabase; + } = {}, ) { this.logger.warn('Creating and syncing a validator node...'); return this.createNode({ ...opts, disableValidator: false, validatorPrivateKeys: new SecretValue(privateKeys) }); } - private async createNode(opts: Partial & { dontStartSequencer?: boolean } = {}) { + private async createNode( + opts: Partial & { + dontStartSequencer?: boolean; + slashingProtectionDb?: SlashingProtectionDatabase; + } = {}, + ) { const nodeIndex = this.nodes.length + 1; const actorPrefix = opts.disableValidator ? 'node' : 'validator'; const { mockGossipSubNetwork } = this.context; @@ -257,6 +266,7 @@ export class EpochsTestContext { ...resolvedConfig, dataDirectory: join(this.context.config.dataDirectory!, randomBytes(8).toString('hex')), validatorPrivateKeys: opts.validatorPrivateKeys ?? new SecretValue([]), + nodeId: resolvedConfig.nodeId || `${actorPrefix}-${nodeIndex}`, p2pEnabled, p2pIp, }, @@ -265,6 +275,7 @@ export class EpochsTestContext { p2pClientDeps: { p2pServiceFactory: mockGossipSubNetwork ? getMockPubSubP2PServiceFactory(mockGossipSubNetwork) : undefined, }, + slashingProtectionDb: opts.slashingProtectionDb, }, { prefilledPublicData: this.context.prefilledPublicData, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index e3a50a720d1b..97cbdd8887ab 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -40,7 +40,12 @@ import { type WorldStateSynchronizer, } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; -import type { BlockProposalOptions, CheckpointProposal, CheckpointProposalOptions } from '@aztec/stdlib/p2p'; +import type { + BlockProposal, + BlockProposalOptions, + CheckpointProposal, + CheckpointProposalOptions, +} from '@aztec/stdlib/p2p'; import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p'; import type { L2BlockBuiltStats } from '@aztec/stdlib/stats'; import { type FailedTx, Tx } from '@aztec/stdlib/tx'; @@ -402,6 +407,7 @@ export class CheckpointProposalJob implements Traceable { const blocksInCheckpoint: L2Block[] = []; const txHashesAlreadyIncluded = new Set(); const initialBlockNumber = BlockNumber(this.syncedToBlockNumber + 1); + const slot = this.slot; // Last block in the checkpoint will usually be flagged as pending broadcast, so we send it along with the checkpoint proposal let blockPendingBroadcast: { block: L2Block; txs: Tx[] } | undefined = undefined; @@ -415,11 +421,7 @@ export class CheckpointProposalJob implements Traceable { const timingInfo = this.timetable.canStartNextBlock(secondsIntoSlot); if (!timingInfo.canStart) { - this.log.debug(`Not enough time left in slot to start another block`, { - slot: this.slot, - blocksBuilt, - secondsIntoSlot, - }); + this.log.debug(`Not enough time left in slot to start another block`, { slot, blocksBuilt, secondsIntoSlot }); break; } @@ -451,50 +453,37 @@ export class CheckpointProposalJob implements Traceable { } else if ('error' in buildResult) { // If there was an error building the block, just exit the loop and give up the rest of the slot if (!(buildResult.error instanceof SequencerInterruptedError)) { - this.log.warn(`Halting block building for slot ${this.slot}`, { - slot: this.slot, - blocksBuilt, - error: buildResult.error, - }); + this.log.warn(`Halting block building for slot ${slot}`, { slot, blocksBuilt, error: buildResult.error }); } break; } const { block, usedTxs } = buildResult; blocksInCheckpoint.push(block); - - // Sync the proposed block to the archiver to make it available - // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building - // If this throws, we abort the entire checkpoint - await this.syncProposedBlockToArchiver(block); - usedTxs.forEach(tx => txHashesAlreadyIncluded.add(tx.txHash.toString())); - // If this is the last block, exit the loop now so we start collecting attestations + // If this is the last block, send the proposed block to the archiver, + // and exit the loop now so we can build the checkpoint and start collecting attestations. if (timingInfo.isLastBlock) { - this.log.verbose(`Completed final block ${blockNumber} for slot ${this.slot}`, { - slot: this.slot, - blockNumber, - blocksBuilt, - }); + await this.syncProposedBlockToArchiver(block); + this.log.verbose(`Completed final block ${blockNumber} for slot ${slot}`, { slot, blockNumber, blocksBuilt }); blockPendingBroadcast = { block, txs: usedTxs }; break; } - // For non-last blocks, broadcast the block proposal (unless we're in fisherman mode) - // If the block is the last one, we'll broadcast it along with the checkpoint at the end of the loop - if (!this.config.fishermanMode) { - const proposal = await this.validatorClient.createBlockProposal( - block.header, - block.indexWithinCheckpoint, - inHash, - block.archive.root, - usedTxs, - this.proposer, - blockProposalOptions, - ); - await this.p2pClient.broadcastProposal(proposal); - } + // Broadcast the block proposal (unless we're in fisherman mode) unless the block is the last one, + // in which case we'll broadcast it along with the checkpoint at the end of the loop. + // Note that we only send the block to the archiver if we manage to create the proposal, so if there's + // a HA error we don't pollute our archiver with a block that won't make it to the chain. + const proposal = await this.createBlockProposal(block, inHash, usedTxs, blockProposalOptions); + + // Sync the proposed block to the archiver to make it available, only after we've managed to sign the proposal. + // We wait for the sync to succeed, as this helps catch consistency errors, even if it means we lose some time for block-building. + // If this throws, we abort the entire checkpoint. + await this.syncProposedBlockToArchiver(block); + + // Once we have a signed proposal and the archiver agreed with our proposed block, then we broadcast it. + proposal && (await this.p2pClient.broadcastProposal(proposal)); // Wait until the next block's start time await this.waitUntilNextSubslot(timingInfo.deadline); @@ -508,6 +497,28 @@ export class CheckpointProposalJob implements Traceable { return { blocksInCheckpoint, blockPendingBroadcast }; } + /** Creates a block proposal for a given block via the validator client (unless in fisherman mode) */ + private createBlockProposal( + block: L2Block, + inHash: Fr, + usedTxs: Tx[], + blockProposalOptions: BlockProposalOptions, + ): Promise { + if (this.config.fishermanMode) { + this.log.info(`Skipping block proposal for block ${block.number} in fisherman mode`); + return Promise.resolve(undefined); + } + return this.validatorClient.createBlockProposal( + block.header, + block.indexWithinCheckpoint, + inHash, + block.archive.root, + usedTxs, + this.proposer, + blockProposalOptions, + ); + } + /** Sleeps until it is time to produce the next block in the slot */ @trackSpan('CheckpointProposalJob.waitUntilNextSubslot') private async waitUntilNextSubslot(nextSubslotStart: number) { diff --git a/yarn-project/validator-client/src/factory.ts b/yarn-project/validator-client/src/factory.ts index b7645d48c485..3d92e338716e 100644 --- a/yarn-project/validator-client/src/factory.ts +++ b/yarn-project/validator-client/src/factory.ts @@ -7,6 +7,7 @@ import type { L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; import type { ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import type { TelemetryClient } from '@aztec/telemetry-client'; +import type { SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types'; import { BlockProposalHandler } from './block_proposal_handler.js'; import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js'; @@ -59,6 +60,7 @@ export function createValidatorClient( epochCache: EpochCache; keyStoreManager: KeystoreManager | undefined; blobClient: BlobClientInterface; + slashingProtectionDb?: SlashingProtectionDatabase; }, ) { if (config.disableValidator || !deps.keyStoreManager) { @@ -79,5 +81,6 @@ export function createValidatorClient( deps.blobClient, deps.dateProvider, deps.telemetry, + deps.slashingProtectionDb, ); } diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index ab751b9b33ba..804869df8474 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -92,6 +92,7 @@ describe('ValidatorClient', () => { let checkpointsBuilder: MockProxy; let worldState: MockProxy; let validatorAccounts: PrivateKeyAccount[]; + let validatorPrivateKeys: `0x${string}`[]; let dateProvider: TestDateProvider; let txProvider: MockProxy; let keyStoreManager: KeystoreManager; @@ -135,7 +136,7 @@ describe('ValidatorClient', () => { haKeyStore.start.mockImplementation(() => Promise.resolve()); haKeyStore.stop.mockImplementation(() => Promise.resolve()); - const validatorPrivateKeys = [generatePrivateKey(), generatePrivateKey()]; + validatorPrivateKeys = [generatePrivateKey(), generatePrivateKey()]; validatorAccounts = validatorPrivateKeys.map(privateKey => privateKeyToAccount(privateKey)); haKeyStore.getAddresses.mockReturnValue(validatorAccounts.map(account => EthAddress.fromString(account.address))); @@ -387,6 +388,23 @@ describe('ValidatorClient', () => { expect(isValid).toBe(true); }); + it('should process block proposal from own validator key (HA peer)', async () => { + const selfSigner = new Secp256k1Signer(Buffer32.fromString(validatorPrivateKeys[0])); + const emptyInHash = computeInHashFromL1ToL2Messages([]); + const selfProposal = await makeBlockProposal({ + blockHeader: proposal.blockHeader, + inHash: emptyInHash, + signer: selfSigner, + }); + + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(selfSigner.address); + + const handleSpy = jest.spyOn(validatorClient.getBlockProposalHandler(), 'handleBlockProposal'); + const isValid = await validatorClient.validateBlockProposal(selfProposal, sender); + expect(isValid).toBe(true); + expect(handleSpy).toHaveBeenCalled(); + }); + it('should return early when escape hatch is open', async () => { epochCache.isEscapeHatchOpenAtSlot.mockResolvedValueOnce(true); diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 60ee9c8ea2d2..2691c7a749e7 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -46,8 +46,12 @@ import type { CheckpointHeader } from '@aztec/stdlib/rollup'; import type { BlockHeader, CheckpointGlobalVariables, Tx } from '@aztec/stdlib/tx'; import { AttestationTimeoutError } from '@aztec/stdlib/validators'; import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client'; -import { createHASigner, createLocalSignerWithProtection } from '@aztec/validator-ha-signer/factory'; -import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types'; +import { + createHASigner, + createLocalSignerWithProtection, + createSignerFromSharedDb, +} from '@aztec/validator-ha-signer/factory'; +import { DutyType, type SigningContext, type SlashingProtectionDatabase } from '@aztec/validator-ha-signer/types'; import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer'; import { EventEmitter } from 'events'; @@ -197,6 +201,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) blobClient: BlobClientInterface, dateProvider: DateProvider = new DateProvider(), telemetry: TelemetryClient = getTelemetryClient(), + slashingProtectionDb?: SlashingProtectionDatabase, ) { const metrics = new ValidatorMetrics(telemetry); const blockProposalValidator = new BlockProposalValidator(epochCache, { @@ -219,7 +224,13 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager); let slashingProtectionSigner: ValidatorHASigner; - if (config.haSigningEnabled) { + if (slashingProtectionDb) { + // Shared database mode: use a pre-existing database (e.g. for testing HA setups). + ({ signer: slashingProtectionSigner } = createSignerFromSharedDb(slashingProtectionDb, config, { + telemetryClient: telemetry, + dateProvider, + })); + } else if (config.haSigningEnabled) { // Multi-node HA mode: use PostgreSQL-backed distributed locking. // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration const haConfig = { @@ -378,13 +389,12 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) return false; } - // Ignore proposals from ourselves (may happen in HA setups) + // Log self-proposals from HA peers (same validator key on different nodes) if (this.getValidatorAddresses().some(addr => addr.equals(proposer))) { - this.log.debug(`Ignoring block proposal from self for slot ${slotNumber}`, { + this.log.verbose(`Processing block proposal from HA peer for slot ${slotNumber}`, { proposer: proposer.toString(), slotNumber, }); - return false; } // Check if we're in the committee (for metrics purposes) diff --git a/yarn-project/validator-ha-signer/src/factory.ts b/yarn-project/validator-ha-signer/src/factory.ts index 3f9e09e69f00..11540567f38b 100644 --- a/yarn-project/validator-ha-signer/src/factory.ts +++ b/yarn-project/validator-ha-signer/src/factory.ts @@ -137,3 +137,35 @@ export async function createLocalSignerWithProtection( return { signer, db }; } + +/** + * Create an in-memory LMDB-backed SlashingProtectionDatabase that can be shared across + * multiple validator nodes in the same process. Used for testing HA setups. + */ +export async function createSharedSlashingProtectionDb( + dateProvider: DateProvider = new DateProvider(), +): Promise { + const kvStore = await createStore('shared-signing-protection', LmdbSlashingProtectionDatabase.SCHEMA_VERSION, { + dataStoreMapSizeKb: 1024 * 1024, + }); + return new LmdbSlashingProtectionDatabase(kvStore, dateProvider); +} + +/** + * Create a ValidatorHASigner backed by a pre-existing SlashingProtectionDatabase. + * Used for testing HA setups where multiple nodes share the same protection database. + */ +export function createSignerFromSharedDb( + db: SlashingProtectionDatabase, + config: Pick< + ValidatorHASignerConfig, + 'nodeId' | 'pollingIntervalMs' | 'signingTimeoutMs' | 'maxStuckDutiesAgeMs' | 'l1Contracts' + >, + deps?: CreateLocalSignerWithProtectionDeps, +): { signer: ValidatorHASigner; db: SlashingProtectionDatabase } { + const telemetryClient = deps?.telemetryClient ?? getTelemetryClient(); + const dateProvider = deps?.dateProvider ?? new DateProvider(); + const metrics = new HASignerMetrics(telemetryClient, config.nodeId, 'SharedSigningProtectionMetrics'); + const signer = new ValidatorHASigner(db, config, { metrics, dateProvider }); + return { signer, db }; +}