diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index 11405b80c3c1..703c33f576a2 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -4,13 +4,17 @@ import type { RollupContract } from '@aztec/ethereum/contracts'; import { BlockNumber } 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 { 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 } from '@aztec/sequencer-client'; +import type { GlobalVariableBuilder, SequencerClient } from '@aztec/sequencer-client'; +import type { SlasherClientInterface } from '@aztec/slasher'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { L2Block, type L2BlockSource } from '@aztec/stdlib/block'; import type { ContractDataSource } from '@aztec/stdlib/contract'; @@ -33,11 +37,15 @@ import { Tx, } from '@aztec/stdlib/tx'; import { getPackageVersion } from '@aztec/stdlib/update-checker'; +import type { ValidatorClient } from '@aztec/validator-client'; -import { readFileSync } from 'fs'; +import { jest } from '@jest/globals'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { type MockProxy, mock } from 'jest-mock-extended'; -import { dirname, resolve } from 'path'; +import { tmpdir } from 'os'; +import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { type AztecNodeConfig, getConfigEnvVars } from './config.js'; import { AztecNodeService } from './server.js'; @@ -390,4 +398,282 @@ describe('aztec node', () => { await expect(node.simulatePublicCalls(tx)).rejects.toThrow(/gas/i); }); }); + + describe('reloadKeystore', () => { + it('throws BadRequestError if no file-based keystore directory is configured', async () => { + // Default node has no keyStoreDirectory set + await expect(node.reloadKeystore()).rejects.toThrow(BadRequestError); + }); + + it('throws BadRequestError if keystore directory is set but validator client is not configured', async () => { + // Satisfies the first check (directory exists) but validatorClient is undefined + nodeConfig.keyStoreDirectory = '/tmp/fake-keystore-dir'; + await expect(node.reloadKeystore()).rejects.toThrow(BadRequestError); + }); + + describe('with file-based keystore', () => { + let keyStoreDir: string; + let validatorClient: MockProxy; + let slasherClient: MockProxy; + let validatorPrivateKey: string; + let nodeWithValidator: AztecNodeService; + + // Helper to build a KeyStore with default coinbase/feeRecipient/remoteSigner. + // Each entry needs only `attester` (required) and optionally `publisher`. + const makeKeyStore = ( + ...validators: Array & Pick, 'publisher'>> + ): KeyStore => ({ + schemaVersion: 1, + validators: validators.map(v => ({ + attester: v.attester, + coinbase: undefined, + feeRecipient: AztecAddress.ZERO, + remoteSigner: undefined, + ...(v.publisher !== undefined ? { publisher: v.publisher } : {}), + })), + }); + + beforeEach(() => { + // Create a temp directory with a keystore file + keyStoreDir = mkdtempSync(join(tmpdir(), 'keystore-test-')); + validatorPrivateKey = generatePrivateKey(); + const keyStore = makeKeyStore({ attester: [validatorPrivateKey as Hex<32>] }); + writeFileSync(join(keyStoreDir, 'keystore.json'), JSON.stringify(keyStore)); + + validatorClient = mock(); + slasherClient = mock(); + + const validatorNodeConfig = { ...nodeConfig, keyStoreDirectory: keyStoreDir }; + + nodeWithValidator = new AztecNodeService( + validatorNodeConfig, + p2p, + l2BlockSource, + mock(), + mock(), + mock(), + mock({ getCommitted: () => merkleTreeOps }), + undefined, + slasherClient, + undefined, + undefined, + 12345, + rollupVersion.toNumber(), + globalVariablesBuilder, + epochCache, + getPackageVersion() ?? '', + new TestCircuitVerifier(), + undefined, + undefined, + undefined, + validatorClient as unknown as ValidatorClient, + new KeystoreManager(keyStore), + ); + }); + + afterEach(() => { + rmSync(keyStoreDir, { recursive: true, force: true }); + }); + + it('reloads keystore from disk and calls validatorClient.reloadKeystore', async () => { + await nodeWithValidator.reloadKeystore(); + expect(validatorClient.reloadKeystore).toHaveBeenCalledTimes(1); + }); + + it('adds new validators to slasher dont-slash-self list on reload', async () => { + // Write a new keystore file with an additional validator + const newPrivateKey = generatePrivateKey(); + writeFileSync( + join(keyStoreDir, 'keystore.json'), + JSON.stringify(makeKeyStore({ attester: [validatorPrivateKey as Hex<32>, newPrivateKey as Hex<32>] })), + ); + + await nodeWithValidator.reloadKeystore(); + + const updateArg = slasherClient.updateConfig.mock.calls[0][0]; + const neverSlashList = updateArg.slashValidatorsNever!; + + const originalAddress = EthAddress.fromString( + privateKeyToAccount(validatorPrivateKey as `0x${string}`).address, + ); + const newAddress = EthAddress.fromString(privateKeyToAccount(newPrivateKey as `0x${string}`).address); + + expect(neverSlashList.some(a => a.equals(originalAddress))).toBe(true); + expect(neverSlashList.some(a => a.equals(newAddress))).toBe(true); + }); + + it('removes validators from slasher dont-slash-self list when removed from keystore', async () => { + // First add two validators + const secondPrivateKey = generatePrivateKey(); + writeFileSync( + join(keyStoreDir, 'keystore.json'), + JSON.stringify(makeKeyStore({ attester: [validatorPrivateKey as Hex<32>, secondPrivateKey as Hex<32>] })), + ); + await nodeWithValidator.reloadKeystore(); + + // Now remove the second validator, keeping only the original + writeFileSync( + join(keyStoreDir, 'keystore.json'), + JSON.stringify(makeKeyStore({ attester: [validatorPrivateKey as Hex<32>] })), + ); + await nodeWithValidator.reloadKeystore(); + + // The second call to updateConfig should only contain the remaining validator + const updateArg = slasherClient.updateConfig.mock.calls[1][0]; + const neverSlashList = updateArg.slashValidatorsNever!; + + const originalAddress = EthAddress.fromString( + privateKeyToAccount(validatorPrivateKey as `0x${string}`).address, + ); + const removedAddress = EthAddress.fromString(privateKeyToAccount(secondPrivateKey as `0x${string}`).address); + + expect(neverSlashList.some(a => a.equals(originalAddress))).toBe(true); + expect(neverSlashList.some(a => a.equals(removedAddress))).toBe(false); + }); + + it('does not update slasher if slashSelfAllowed is true', async () => { + (nodeWithValidator as any).config.slashSelfAllowed = true; + await nodeWithValidator.reloadKeystore(); + + expect(validatorClient.reloadKeystore).toHaveBeenCalledTimes(1); + expect(slasherClient.updateConfig).not.toHaveBeenCalled(); + }); + + it('reloads keystore with remote signer validators from disk', async () => { + // Update keystore file to add a remote signer validator alongside the local key validator. + // This verifies the full reload path supports mixed local + remote signer keystores: + // file-on-disk -> loadKeystores -> KeystoreManager -> validateSigners (mocked) -> + // ValidatorClient.reloadKeystore -> NodeKeystoreAdapter (creates RemoteSigner instances) + const remoteSignerUrl = 'https://web3signer.example.com:9000'; + const remoteAttesterAddress = EthAddress.random(); + writeFileSync( + join(keyStoreDir, 'keystore.json'), + JSON.stringify( + makeKeyStore( + { attester: [validatorPrivateKey as Hex<32>] }, + { attester: { address: remoteAttesterAddress, remoteSignerUrl } }, + ), + ), + ); + + // Mock RemoteSigner.validateAccess to avoid a real HTTP call to web3signer. + // validateSigners() calls this to verify each remote signer URL is reachable + // and that the requested addresses are available. + const validateSpy = jest.spyOn(RemoteSigner, 'validateAccess').mockImplementation(() => Promise.resolve()); + + try { + await nodeWithValidator.reloadKeystore(); + + // Verify RemoteSigner.validateAccess was called with the correct URL and address + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy).toHaveBeenCalledWith( + remoteSignerUrl, + expect.arrayContaining([remoteAttesterAddress.toString().toLowerCase()]), + ); + + // Verify validatorClient.reloadKeystore was called (reload succeeded) + expect(validatorClient.reloadKeystore).toHaveBeenCalledTimes(1); + + // Verify the new KeystoreManager was passed through with both validators + const passedManager = validatorClient.reloadKeystore.mock.calls[0][0] as KeystoreManager; + expect(passedManager.getValidatorCount()).toBe(2); + + // Verify slasher list includes both the local and remote validator addresses + const updateArg = slasherClient.updateConfig.mock.calls[0][0]; + const neverSlashList = updateArg.slashValidatorsNever!; + expect(neverSlashList.some(a => a.equals(remoteAttesterAddress))).toBe(true); + } finally { + validateSpy.mockRestore(); + } + }); + + it('rejects reload when remote signer validation fails', async () => { + // If RemoteSigner.validateAccess fails (e.g. web3signer unreachable or address not found), + // the reload should be rejected and the old keystore should remain intact. + const remoteSignerUrl = 'https://web3signer.example.com:9000'; + const remoteAttesterAddress = EthAddress.random(); + writeFileSync( + join(keyStoreDir, 'keystore.json'), + JSON.stringify( + makeKeyStore( + { attester: [validatorPrivateKey as Hex<32>] }, + // EthAddress has toJSON() so JSON.stringify serializes it as a hex string. + { attester: { address: remoteAttesterAddress, remoteSignerUrl } }, + ), + ), + ); + + // Mock RemoteSigner.validateAccess to reject — simulates unreachable web3signer + const validateSpy = jest + .spyOn(RemoteSigner, 'validateAccess') + .mockRejectedValue(new Error('Unable to connect to web3signer')); + + try { + await expect(nodeWithValidator.reloadKeystore()).rejects.toThrow(/Unable to connect to web3signer/); + + // Validator client should NOT have been called (reload rejected before mutation) + expect(validatorClient.reloadKeystore).not.toHaveBeenCalled(); + } finally { + validateSpy.mockRestore(); + } + }); + + it('rejects reload when new validator has a publisher key not in the L1 signers', async () => { + // Initial keystore has validator with publisherKeyA + const publisherKeyA = generatePrivateKey(); + const publisherKeyB = generatePrivateKey(); // different, not in L1 signers + + const initialKeyStore = makeKeyStore({ + attester: [validatorPrivateKey as Hex<32>], + publisher: [publisherKeyA as Hex<32>], + }); + + // Recreate node with a truthy sequencer so the publisher validation path runs. + // Only truthiness matters: the code checks `if (this.keyStoreManager && this.sequencer)` + // and the validation logic uses keyStoreManager, not sequencer methods. + // The test expects rejection before sequencer.updatePublisherNodeKeyStore() is reached. + const nodeWithSequencer = new AztecNodeService( + { ...nodeConfig, keyStoreDirectory: keyStoreDir }, + p2p, + l2BlockSource, + mock(), + mock(), + mock(), + mock({ getCommitted: () => merkleTreeOps }), + {} as SequencerClient, + slasherClient, + undefined, + undefined, + 12345, + rollupVersion.toNumber(), + globalVariablesBuilder, + epochCache, + getPackageVersion() ?? '', + new TestCircuitVerifier(), + undefined, + undefined, + undefined, + validatorClient as unknown as ValidatorClient, + new KeystoreManager(initialKeyStore), + ); + + // Write new keystore: new validator uses publisherKeyB (not in the L1 signers) + const newValidatorKey = generatePrivateKey(); + writeFileSync( + join(keyStoreDir, 'keystore.json'), + JSON.stringify( + makeKeyStore( + { attester: [validatorPrivateKey as Hex<32>], publisher: [publisherKeyA as Hex<32>] }, + { attester: [newValidatorKey as Hex<32>], publisher: [publisherKeyB as Hex<32>] }, + ), + ), + ); + + await expect(nodeWithSequencer.reloadKeystore()).rejects.toThrow(BadRequestError); + + // reload rejected before mutation + expect(validatorClient.reloadKeystore).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 181b0f60e6f5..a8f0c1a758dd 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -8,7 +8,7 @@ import { getPublicClient } from '@aztec/ethereum/client'; import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { compactArray, pick } from '@aztec/foundation/collection'; +import { 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'; @@ -141,6 +141,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { private telemetry: TelemetryClient = getTelemetryClient(), private log = createLogger('node'), private blobClient?: BlobClientInterface, + private validatorClient?: ValidatorClient, + private keyStoreManager?: KeystoreManager, ) { this.metrics = new NodeMetrics(telemetry, 'AztecNodeService'); this.tracer = telemetry.getTracer('AztecNodeService'); @@ -489,6 +491,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { telemetry, log, blobClient, + validatorClient, + keyStoreManager, ); } @@ -1376,6 +1380,74 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { } } + public async reloadKeystore(): Promise { + if (!this.config.keyStoreDirectory?.length) { + throw new BadRequestError( + 'Cannot reload keystore: node is not using a file-based keystore. ' + + 'Set KEY_STORE_DIRECTORY to use file-based keystores.', + ); + } + if (!this.validatorClient) { + throw new BadRequestError('Cannot reload keystore: validator is not enabled.'); + } + + this.log.info('Reloading keystore from disk'); + + // Re-read and validate keystore files + const keyStores = loadKeystores(this.config.keyStoreDirectory); + const newManager = new KeystoreManager(mergeKeystores(keyStores)); + await newManager.validateSigners(); + ValidatorClient.validateKeyStoreConfiguration(newManager, this.log); + + // Validate that every validator's publisher keys overlap with the L1 signers + // that were initialized at startup. Publishers cannot be hot-reloaded, so a + // validator with a publisher key that doesn't match any existing L1 signer + // would silently fail on every proposer slot. + if (this.keyStoreManager && this.sequencer) { + const oldAdapter = NodeKeystoreAdapter.fromKeyStoreManager(this.keyStoreManager); + const availablePublishers = new Set( + oldAdapter + .getAttesterAddresses() + .flatMap(a => oldAdapter.getPublisherAddresses(a).map(p => p.toString().toLowerCase())), + ); + + const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager); + for (const attester of newAdapter.getAttesterAddresses()) { + const pubs = newAdapter.getPublisherAddresses(attester); + if (pubs.length > 0 && !pubs.some(p => availablePublishers.has(p.toString().toLowerCase()))) { + throw new BadRequestError( + `Cannot reload keystore: validator ${attester} has publisher keys ` + + `[${pubs.map(p => p.toString()).join(', ')}] but none match the L1 signers initialized at startup ` + + `[${[...availablePublishers].join(', ')}]. Publishers cannot be hot-reloaded — ` + + `use an existing publisher key or restart the node.`, + ); + } + } + } + + // Update the validator client (coinbase, feeRecipient, attester keys) + this.validatorClient.reloadKeystore(newManager); + + // Update the publisher factory's keystore so newly-added validators + // can be matched to existing publisher keys when proposing blocks. + if (this.sequencer) { + const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager); + this.sequencer.updatePublisherNodeKeyStore(newAdapter); + } + + // Update slasher's "don't-slash-self" list with new validator addresses + if (this.slasherClient && !this.config.slashSelfAllowed) { + const newAddresses = NodeKeystoreAdapter.fromKeyStoreManager(newManager).getAddresses(); + const slashValidatorsNever = unique( + [...(this.config.slashValidatorsNever ?? []), ...newAddresses].map(a => a.toString()), + ).map(EthAddress.fromString); + this.slasherClient.updateConfig({ slashValidatorsNever }); + } + + this.keyStoreManager = newManager; + this.log.info('Keystore reloaded: coinbase, feeRecipient, and attester keys updated'); + } + #getInitialHeaderHash(): Promise { if (!this.initialHeaderHashPromise) { this.initialHeaderHashPromise = this.worldStateSynchronizer.getCommitted().getInitialHeader().hash(); diff --git a/yarn-project/end-to-end/src/e2e_sequencer/reload_keystore.test.ts b/yarn-project/end-to-end/src/e2e_sequencer/reload_keystore.test.ts new file mode 100644 index 000000000000..09deed9db69d --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_sequencer/reload_keystore.test.ts @@ -0,0 +1,216 @@ +import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; +import { NO_WAIT } from '@aztec/aztec.js/contracts'; +import { ContractDeployer } from '@aztec/aztec.js/deployment'; +import { Fr } from '@aztec/aztec.js/fields'; +import { type AztecNode, waitForTx } from '@aztec/aztec.js/node'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { SecretValue } from '@aztec/foundation/config'; +import type { EthPrivateKey } from '@aztec/node-keystore'; +import { StatefulTestContractArtifact } from '@aztec/noir-test-contracts.js/StatefulTest'; +import type { SequencerClient } from '@aztec/sequencer-client'; +import type { TestSequencer, TestSequencerClient } from '@aztec/sequencer-client/test'; +import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; +import type { ValidatorClient } from '@aztec/validator-client'; + +import { jest } from '@jest/globals'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { getPrivateKeyFromIndex, setup } from '../fixtures/utils.js'; + +const VALIDATOR_KEY_INDICES = [0, 2, 4, 5]; +const PUBLISHER_KEY_INDEX = 3; + +// 4 validators staked on L1, committee size 4 → quorum = floor(4*2/3)+1 = 3. +// Only 3 validators are in the initial keystore (enough for quorum). +// After reload, the 4th validator is added. +const VALIDATOR_COUNT = 4; +const COMMITTEE_SIZE = VALIDATOR_COUNT; +const INITIAL_KEYSTORE_COUNT = 3; + +describe('e2e_reload_keystore', () => { + jest.setTimeout(300_000); + + let teardown: () => Promise; + let aztecNode: AztecNode; + let aztecNodeAdmin: AztecNodeAdmin | undefined; + let wallet: Wallet; + let ownerAddress: AztecAddress; + let keyStoreDirectory: string; + let sequencerClient: SequencerClient | undefined; + + const validatorKeys: EthPrivateKey[] = []; + const validatorAddresses: string[] = []; + let publisherKey: EthPrivateKey; + + const initialCoinbase = EthAddress.fromNumber(42); + const initialFeeRecipient = AztecAddress.fromNumber(42); + + const artifact = StatefulTestContractArtifact; + + beforeAll(async () => { + // Derive keys from the test mnemonic (these accounts are funded in Anvil) + for (const idx of VALIDATOR_KEY_INDICES) { + const key = `0x${getPrivateKeyFromIndex(idx)!.toString('hex')}` as EthPrivateKey; + validatorKeys.push(key); + validatorAddresses.push(privateKeyToAccount(key).address); + } + publisherKey = `0x${getPrivateKeyFromIndex(PUBLISHER_KEY_INDEX)!.toString('hex')}` as EthPrivateKey; + + // Create temp directory for keystore files + keyStoreDirectory = await mkdtemp(join(tmpdir(), 'reload-keystore-')); + + // Write initial keystore: first 3 validators only (validator 4 is deliberately excluded). + // All share the same coinbase X so we can detect a change after reload. + const initialKeystore = { + schemaVersion: 1, + validators: validatorKeys.slice(0, INITIAL_KEYSTORE_COUNT).map(key => ({ + attester: key, + coinbase: initialCoinbase.toChecksumString(), + publisher: [publisherKey], + feeRecipient: initialFeeRecipient.toString(), + })), + }; + await writeFile(join(keyStoreDirectory, 'keystore.json'), JSON.stringify(initialKeystore, null, 2)); + + // Stake ALL 4 validators on L1 so they are part of the committee + const initialValidators = validatorKeys.map((key, i) => ({ + attester: EthAddress.fromString(validatorAddresses[i]), + withdrawer: EthAddress.fromString(validatorAddresses[i]), + privateKey: key, + bn254SecretKey: new SecretValue(Fr.random().toBigInt()), + })); + + ({ + teardown, + aztecNode, + aztecNodeAdmin, + wallet, + accounts: [ownerAddress], + sequencer: sequencerClient, + } = await setup(1, { + initialValidators, + aztecTargetCommitteeSize: COMMITTEE_SIZE, + keyStoreDirectory, + minTxsPerBlock: 1, + maxTxsPerBlock: 1, + })); + + if (!aztecNodeAdmin) { + throw new Error('Aztec node admin API must be available for this test'); + } + }); + + afterAll(async () => { + await teardown(); + await rm(keyStoreDirectory, { recursive: true, force: true }); + }); + + it('should reload keystore, add a new validator, and use updated coinbase in blocks', async () => { + // Access the sequencer's validator client to inspect keystore state + const sequencer = (sequencerClient! as TestSequencerClient).getSequencer(); + const validatorClient: ValidatorClient = (sequencer as TestSequencer).validatorClient; + + // Verify initial keystore state and block production + // Only the first 3 validators should be loaded + const initialAddrs = validatorClient.getValidatorAddresses(); + expect(initialAddrs).toHaveLength(INITIAL_KEYSTORE_COUNT); + for (let i = 0; i < INITIAL_KEYSTORE_COUNT; i++) { + const attestor = EthAddress.fromString(validatorAddresses[i]); + expect(validatorClient.getCoinbaseForAttestor(attestor)).toEqual(initialCoinbase); + expect(validatorClient.getFeeRecipientForAttestor(attestor)).toEqual(initialFeeRecipient); + } + + // Validator 4 should NOT be in the keystore yet + const addr4Lower = validatorAddresses[3].toLowerCase(); + expect(initialAddrs.map(a => a.toString().toLowerCase())).not.toContain(addr4Lower); + + // Send a tx and verify the block uses the initial coinbase + const deployer = new ContractDeployer(artifact, wallet); + const sentTx1 = await deployer.deploy(ownerAddress, ownerAddress, 1).send({ + from: ownerAddress, + contractAddressSalt: new Fr(1), + skipClassPublication: true, + skipInstancePublication: true, + wait: NO_WAIT, + }); + const receipt1 = await waitForTx(aztecNode, sentTx1); + + const block1 = await aztecNode.getBlock(BlockNumber(receipt1.blockNumber!)); + expect(block1).toBeDefined(); + expect(block1!.header.globalVariables.coinbase.toString().toLowerCase()).toEqual( + initialCoinbase.toString().toLowerCase(), + ); + + // Write updated keystore and reload + // Each validator gets its own new coinbase so we can verify per-validator updates. + const newCoinbases = VALIDATOR_KEY_INDICES.map((_, i) => EthAddress.fromNumber(100 + i)); + const newFeeRecipients = VALIDATOR_KEY_INDICES.map((_, i) => AztecAddress.fromNumber(100 + i)); + + // Build updated keystore: all 4 validators (including the previously-excluded validator 4) + const updatedKeystore = { + schemaVersion: 1, + validators: validatorKeys.map((key, i) => ({ + attester: key, + coinbase: newCoinbases[i].toChecksumString(), + publisher: [publisherKey], + feeRecipient: newFeeRecipients[i].toString(), + })), + }; + await writeFile(join(keyStoreDirectory, 'keystore.json'), JSON.stringify(updatedKeystore, null, 2)); + + // Reload keystore via the admin API + await aztecNodeAdmin!.reloadKeystore(); + + // Verify the reload took effect + // All 4 validators should now be loaded + const updatedAddrs = validatorClient.getValidatorAddresses(); + expect(updatedAddrs).toHaveLength(VALIDATOR_COUNT); + + for (let i = 0; i < VALIDATOR_COUNT; i++) { + const attestor = EthAddress.fromString(validatorAddresses[i]); + expect(validatorClient.getCoinbaseForAttestor(attestor)).toEqual(newCoinbases[i]); + expect(validatorClient.getFeeRecipientForAttestor(attestor)).toEqual(newFeeRecipients[i]); + } + + // Specifically confirm validator 4 is now present + expect(updatedAddrs.map(a => a.toString().toLowerCase())).toContain(addr4Lower); + + // Deterministically prove validator 4 CAN publish blocks + // Directly ask the publisher factory to create a publisher for validator 4. + // This exercises the full chain: keystore lookup → publisher filter → L1 signer match. + // If the publisher key weren't in the L1TxUtils pool, this would throw. + const publisherFactory = (sequencer as TestSequencer).publisherFactory; + const validator4Attestor = EthAddress.fromString(validatorAddresses[3]); + const { attestorAddress: returnedAttestor, publisher: validator4Publisher } = + await publisherFactory.create(validator4Attestor); + + expect(returnedAttestor.equals(validator4Attestor)).toBe(true); + expect(validator4Publisher).toBeDefined(); + expect(validator4Publisher.getSenderAddress()).toBeDefined(); + + // Verify block production uses new coinbases (not old) + // Send a tx and confirm the block uses one of the new per-validator coinbases. + // Whichever validator is the proposer, its coinbase must be from the reloaded keystore. + const allNewCoinbasesLower = newCoinbases.map(c => c.toString().toLowerCase()); + + const sentTx2 = await deployer.deploy(ownerAddress, ownerAddress, 2).send({ + from: ownerAddress, + contractAddressSalt: new Fr(2), + skipClassPublication: true, + skipInstancePublication: true, + wait: NO_WAIT, + }); + const receipt2 = await waitForTx(aztecNode, sentTx2); + + const block2 = await aztecNode.getBlock(BlockNumber(receipt2.blockNumber!)); + expect(block2).toBeDefined(); + + const actualCoinbase = block2!.header.globalVariables.coinbase.toString().toLowerCase(); + expect(allNewCoinbasesLower).toContain(actualCoinbase); + expect(actualCoinbase).not.toEqual(initialCoinbase.toString().toLowerCase()); + }); +}); diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index 9f1d93d36067..0cb836b019f1 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -208,6 +208,11 @@ export class SequencerClient { return this.sequencer; } + /** Updates the publisher factory's node keystore adapter after a keystore reload. */ + public updatePublisherNodeKeyStore(adapter: NodeKeystoreAdapter): void { + this.sequencer.updatePublisherNodeKeyStore(adapter); + } + get validatorAddresses(): EthAddress[] | undefined { return this.sequencer.getValidatorAddresses(); } diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts index 0f960c656d32..5027eb16e835 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts @@ -140,6 +140,61 @@ describe('SequencerPublisherFactory', () => { expect(result.attestorAddress).toBe(validatorAddress); }); + it('should reject validator added via updateNodeKeyStore with a different publisher key', async () => { + // Initial keystore knows validator → publisherAddress + mockNodeKeyStore.getPublisherAddresses.mockReturnValue([publisherAddress]); + + // After updateNodeKeyStore, a new validator maps to a DIFFERENT publisher key + const newValidatorAddress = EthAddress.random(); + const differentPublisherAddress = EthAddress.random(); + const updatedKeyStore = mock(); + updatedKeyStore.getPublisherAddresses.mockImplementation((addr: EthAddress) => { + if (addr.equals(newValidatorAddress)) { + return [differentPublisherAddress]; // not in L1TxUtils pool + } + return [publisherAddress]; + }); + + factory.updateNodeKeyStore(updatedKeyStore); + + // The L1TxUtils pool only has publisherAddress, not differentPublisherAddress + mockL1TxUtils.getSenderAddress.mockReturnValue(publisherAddress); + mockPublisherManager.getAvailablePublisher.mockRejectedValueOnce( + new Error('Failed to find an available publisher.'), + ); + + await expect(factory.create(newValidatorAddress)).rejects.toThrow('Failed to find an available publisher.'); + + // Verify the filter rejects the available publisher (wrong key) + const filterFn = mockPublisherManager.getAvailablePublisher.mock.calls[0][0]!; + expect(filterFn(mockL1TxUtils)).toBe(false); + }); + + it('should allow validator added via updateNodeKeyStore with an existing publisher key', async () => { + // A new validator maps to the SAME publisher key that's already in the L1TxUtils pool + const newValidatorAddress = EthAddress.random(); + const updatedKeyStore = mock(); + updatedKeyStore.getPublisherAddresses.mockImplementation((addr: EthAddress) => { + if (addr.equals(newValidatorAddress)) { + return [publisherAddress]; // same key as L1TxUtils + } + return []; + }); + + factory.updateNodeKeyStore(updatedKeyStore); + + mockL1TxUtils.getSenderAddress.mockReturnValue(publisherAddress); + + const result = await factory.create(newValidatorAddress); + + // Verify the filter accepts the publisher (same key) + const filterFn = mockPublisherManager.getAvailablePublisher.mock.calls[0][0]!; + expect(filterFn(mockL1TxUtils)).toBe(true); + + expect(result.attestorAddress).toBe(newValidatorAddress); + expect(result.publisher).toBeDefined(); + }); + it('should create SequencerPublisher with correct configuration', async () => { mockNodeKeyStore.getAttestorForPublisher.mockReturnValue(attestorAddress); const mockSlashingProposer = { address: EthAddress.random() }; diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts index 2942b7ec3be1..98b6424f68df 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts @@ -46,6 +46,15 @@ export class SequencerPublisherFactory { this.publisherMetrics = new SequencerPublisherMetrics(deps.telemetry, 'SequencerPublisher'); this.logger = deps.logger ?? createLogger('sequencer'); } + + /** + * Updates the node keystore adapter used for publisher lookups. + * Called when the keystore is reloaded at runtime to reflect new validator-publisher mappings. + */ + public updateNodeKeyStore(adapter: NodeKeystoreAdapter): void { + this.deps.nodeKeyStore = adapter; + } + /** * Creates a new SequencerPublisher instance. * @param _validatorAddress - The address of the validator that will be using the publisher. diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 508ffbaf8344..953f7e14e069 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -25,7 +25,7 @@ import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import { pickFromSchema } from '@aztec/stdlib/schemas'; import { MerkleTreeId } from '@aztec/stdlib/trees'; import { Attributes, type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client'; -import { FullNodeCheckpointsBuilder, type ValidatorClient } from '@aztec/validator-client'; +import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client'; import EventEmitter from 'node:events'; @@ -866,6 +866,11 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { epochOrSlot: expect.any(BigInt), }); }); + + it('reloadKeystore', async () => { + await context.client.reloadKeystore(); + }); }); class MockAztecNodeAdmin implements AztecNodeAdmin { @@ -188,4 +192,7 @@ class MockAztecNodeAdmin implements AztecNodeAdmin { resumeSync(): Promise { return Promise.resolve(); } + reloadKeystore(): Promise { + return Promise.resolve(); + } } diff --git a/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts b/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts index 1003734261f8..500e2451af8a 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts @@ -50,6 +50,26 @@ export interface AztecNodeAdmin { /** Returns all offenses applicable for the given round. */ getSlashOffenses(round: bigint | 'all' | 'current'): Promise; + + /** + * Reloads keystore configuration from disk. + * + * What is updated: + * - Validator attester keys + * - Coinbase address per validator + * - Fee recipient address per validator + * + * What is NOT updated (requires node restart): + * - L1 publisher signers (the funded accounts that send L1 transactions) + * - Prover keys + * - HA signer PostgreSQL connections + * + * Notes: + * - New validators must use a publisher key that was already configured at node + * startup (or omit the publisher field to fall back to the attester key). + * A validator with an unknown publisher key will cause the reload to be rejected. + */ + reloadKeystore(): Promise; } // L1 contracts are not mutable via admin updates. @@ -88,6 +108,7 @@ export const AztecNodeAdminApiSchema: ApiSchemaFor = { .function() .args(z.union([z.bigint(), z.literal('all'), z.literal('current')])) .returns(z.array(OffenseSchema)), + reloadKeystore: z.function().returns(z.void()), }; export function createAztecNodeAdminClient( diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 62fa685ed8a8..3686d0e1449f 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -50,9 +50,32 @@ import type { FullNodeCheckpointsBuilder, } from './checkpoint_builder.js'; import { type ValidatorClientConfig, validatorClientConfigMappings } from './config.js'; -import type { HAKeyStore } from './key_store/ha_key_store.js'; +import { HAKeyStore } from './key_store/ha_key_store.js'; import { ValidatorClient } from './validator.js'; +function makeKeyStore(validator: { + attester: Hex<32>[] | Hex<32>; + coinbase?: EthAddress; + feeRecipient?: AztecAddress; + publisher?: Hex<32>[]; +}): KeyStore { + return { + schemaVersion: 1, + slasher: undefined, + prover: undefined, + remoteSigner: undefined, + validators: [ + { + attester: Array.isArray(validator.attester) ? validator.attester : [validator.attester], + feeRecipient: validator.feeRecipient ?? AztecAddress.ZERO, + coinbase: validator.coinbase, + remoteSigner: undefined, + publisher: validator.publisher ?? [], + }, + ], + }; +} + describe('ValidatorClient', () => { let config: ValidatorClientConfig & Pick & { @@ -127,22 +150,7 @@ describe('ValidatorClient', () => { maxStuckDutiesAgeMs: 72000, }; - const keyStore: KeyStore = { - schemaVersion: 1, - slasher: undefined, - prover: undefined, - remoteSigner: undefined, - validators: [ - { - attester: validatorPrivateKeys.map(key => key as Hex<32>), - feeRecipient: AztecAddress.ZERO, - coinbase: undefined, - remoteSigner: undefined, - publisher: [], - }, - ], - }; - keyStoreManager = new KeystoreManager(keyStore); + keyStoreManager = new KeystoreManager(makeKeyStore({ attester: validatorPrivateKeys.map(key => key as Hex<32>) })); validatorClient = await ValidatorClient.new( config, @@ -825,4 +833,124 @@ describe('ValidatorClient', () => { expect(haKeyStore.stop).toHaveBeenCalledTimes(1); }); }); + + describe('reloadKeystore', () => { + // build a KeystoreManager from a single-validator KeyStore and reload. + const reloadWith = (overrides: Parameters[0]) => { + const manager = new KeystoreManager(makeKeyStore(overrides)); + validatorClient.reloadKeystore(manager); + return manager; + }; + + const allKeys = () => config.validatorPrivateKeys!.getValue().map(k => k as Hex<32>); + + it('should update coinbase after reload', () => { + const newCoinbase = EthAddress.random(); + reloadWith({ attester: allKeys(), coinbase: newCoinbase }); + + const attestorAddress = EthAddress.fromString(validatorAccounts[0].address); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).toEqual(newCoinbase); + }); + + it('should update fee recipient after reload', async () => { + const newFeeRecipient = await AztecAddress.random(); + reloadWith({ attester: allKeys(), feeRecipient: newFeeRecipient }); + + const attestorAddress = EthAddress.fromString(validatorAccounts[0].address); + expect(validatorClient.getFeeRecipientForAttestor(attestorAddress)).toEqual(newFeeRecipient); + }); + + it('should add new validator after reload', () => { + const newPrivateKey = generatePrivateKey(); + const newAccount = privateKeyToAccount(newPrivateKey); + reloadWith({ attester: [...allKeys(), newPrivateKey as Hex<32>] }); + + const addresses = validatorClient.getValidatorAddresses(); + expect(addresses).toHaveLength(3); + expect(addresses.some(a => a.equals(EthAddress.fromString(newAccount.address)))).toBe(true); + }); + + it('should update attester key after reload', () => { + const newPrivateKey = generatePrivateKey(); + const newAccount = privateKeyToAccount(newPrivateKey); + reloadWith({ attester: newPrivateKey as Hex<32> }); + + const addresses = validatorClient.getValidatorAddresses(); + expect(addresses).toHaveLength(1); + expect(addresses[0]).toEqual(EthAddress.fromString(newAccount.address)); + }); + + it('should remove a validator after reload', () => { + const remainingKey = config.validatorPrivateKeys!.getValue()[0] as Hex<32>; + const removedAccount = validatorAccounts[1]; + reloadWith({ attester: remainingKey }); + + const addresses = validatorClient.getValidatorAddresses(); + expect(addresses).toHaveLength(1); + expect(addresses.some(a => a.equals(EthAddress.fromString(removedAccount.address)))).toBe(false); + + // Accessing the removed validator's coinbase should throw + expect(() => validatorClient.getCoinbaseForAttestor(EthAddress.fromString(removedAccount.address))).toThrow( + /not found in any validator configuration/, + ); + }); + + it('should change coinbase and no longer return the old one', () => { + const attestorAddress = EthAddress.fromString(validatorAccounts[0].address); + + const oldCoinbase = EthAddress.random(); + reloadWith({ attester: allKeys(), coinbase: oldCoinbase }); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).toEqual(oldCoinbase); + + const newCoinbase = EthAddress.random(); + reloadWith({ attester: allKeys(), coinbase: newCoinbase }); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).toEqual(newCoinbase); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).not.toEqual(oldCoinbase); + }); + + it('should reset coinbase to attester fallback when removed', () => { + const attestorAddress = EthAddress.fromString(validatorAccounts[0].address); + + const explicitCoinbase = EthAddress.random(); + reloadWith({ attester: allKeys(), coinbase: explicitCoinbase }); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).toEqual(explicitCoinbase); + + // Reload without coinbase — falls back to the attester address itself + reloadWith({ attester: allKeys() }); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).toEqual(attestorAddress); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).not.toEqual(explicitCoinbase); + }); + + it('should change fee recipient and no longer return the old one', async () => { + const attestorAddress = EthAddress.fromString(validatorAccounts[0].address); + + const oldFeeRecipient = await AztecAddress.random(); + reloadWith({ attester: allKeys(), feeRecipient: oldFeeRecipient }); + expect(validatorClient.getFeeRecipientForAttestor(attestorAddress)).toEqual(oldFeeRecipient); + + const newFeeRecipient = await AztecAddress.random(); + reloadWith({ attester: allKeys(), feeRecipient: newFeeRecipient }); + expect(validatorClient.getFeeRecipientForAttestor(attestorAddress)).toEqual(newFeeRecipient); + expect(validatorClient.getFeeRecipientForAttestor(attestorAddress)).not.toEqual(oldFeeRecipient); + }); + + it('should preserve HA signer and wrap new adapter in HAKeyStore after reload', () => { + // Simulate HA mode by setting the haSigner and wrapping in HAKeyStore + const mockHASigner = { nodeId: 'test-ha-node' }; + (validatorClient as any).haSigner = mockHASigner; + (validatorClient as any).keyStore = haKeyStore; + + const newCoinbase = EthAddress.random(); + reloadWith({ attester: allKeys(), coinbase: newCoinbase }); + + // Verify the keyStore is an HAKeyStore wrapping the same haSigner + const keyStoreAfterReload = (validatorClient as any).keyStore; + expect(keyStoreAfterReload).toBeInstanceOf(HAKeyStore); + expect((keyStoreAfterReload as any).haSigner).toBe(mockHASigner); + + // Verify the new coinbase is accessible through the HAKeyStore + const attestorAddress = EthAddress.fromString(validatorAccounts[0].address); + expect(validatorClient.getCoinbaseForAttestor(attestorAddress)).toEqual(newCoinbase); + }); + }); }); diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 65fd888baba4..720a6c9e9efb 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -46,6 +46,7 @@ import { AttestationTimeoutError } from '@aztec/stdlib/validators'; import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client'; import { createHASigner } from '@aztec/validator-ha-signer/factory'; import { DutyType, type SigningContext } from '@aztec/validator-ha-signer/types'; +import type { ValidatorHASigner } from '@aztec/validator-ha-signer/validator-ha-signer'; import { EventEmitter } from 'events'; import type { TypedDataDefinition } from 'viem'; @@ -76,6 +77,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) private validationService: ValidationService; private metrics: ValidatorMetrics; private log: Logger; + private haSigner?: ValidatorHASigner; // Whether it has already registered handlers on the p2p client private hasRegisteredHandlers = false; @@ -204,7 +206,9 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) telemetry, ); - let validatorKeyStore: ExtendedValidatorKeyStore = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager); + const nodeKeystoreAdapter = NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager); + let validatorKeyStore: ExtendedValidatorKeyStore = nodeKeystoreAdapter; + let haSigner: ValidatorHASigner | undefined; if (config.haSigningEnabled) { // If maxStuckDutiesAgeMs is not explicitly set, compute it from Aztec slot duration const haConfig = { @@ -212,7 +216,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) maxStuckDutiesAgeMs: config.maxStuckDutiesAgeMs ?? epochCache.getL1Constants().slotDuration * 2 * 1000, }; const { signer } = await createHASigner(haConfig); - validatorKeyStore = new HAKeyStore(validatorKeyStore, signer); + haSigner = signer; + validatorKeyStore = new HAKeyStore(nodeKeystoreAdapter, signer); } const validator = new ValidatorClient( @@ -229,6 +234,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) dateProvider, telemetry, ); + validator.haSigner = haSigner; return validator; } @@ -263,6 +269,16 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.config = { ...this.config, ...config }; } + public reloadKeystore(newManager: KeystoreManager): void { + const newAdapter = NodeKeystoreAdapter.fromKeyStoreManager(newManager); + if (this.haSigner) { + this.keyStore = new HAKeyStore(newAdapter, this.haSigner); + } else { + this.keyStore = newAdapter; + } + this.validationService = new ValidationService(this.keyStore, this.log.createChild('validation-service')); + } + public async start() { if (this.epochCacheUpdateLoop.isRunning()) { this.log.warn(`Validator client already started`);