diff --git a/yarn-project/aztec-node/src/aztec-node/config.ts b/yarn-project/aztec-node/src/aztec-node/config.ts index 29c5fd51b7a1..1ee6d03fd265 100644 --- a/yarn-project/aztec-node/src/aztec-node/config.ts +++ b/yarn-project/aztec-node/src/aztec-node/config.ts @@ -92,7 +92,7 @@ export function getConfigEnvVars(): AztecNodeConfig { type ConfigRequiredToBuildKeyStore = TxSenderConfig & SequencerClientConfig & SharedNodeConfig & ValidatorClientConfig; -function createKeyStoreFromWeb3Signer(config: ConfigRequiredToBuildKeyStore) { +function createKeyStoreFromWeb3Signer(config: ConfigRequiredToBuildKeyStore): KeyStore | undefined { const validatorKeyStores: ValidatorKeyStore[] = []; if ( @@ -122,7 +122,7 @@ function createKeyStoreFromWeb3Signer(config: ConfigRequiredToBuildKeyStore) { return keyStore; } -function createKeyStoreFromPrivateKeys(config: ConfigRequiredToBuildKeyStore) { +function createKeyStoreFromPrivateKeys(config: ConfigRequiredToBuildKeyStore): KeyStore | undefined { const validatorKeyStores: ValidatorKeyStore[] = []; const ethPrivateKeys = config.validatorPrivateKeys ? config.validatorPrivateKeys.getValue().map(x => ethPrivateKeySchema.parse(x)) @@ -156,7 +156,9 @@ function createKeyStoreFromPrivateKeys(config: ConfigRequiredToBuildKeyStore) { return keyStore; } -export function createKeyStoreForValidator(config: TxSenderConfig & SequencerClientConfig & SharedNodeConfig) { +export function createKeyStoreForValidator( + config: TxSenderConfig & SequencerClientConfig & SharedNodeConfig, +): KeyStore | undefined { if (config.web3SignerUrl !== undefined && config.web3SignerUrl.length > 0) { return createKeyStoreFromWeb3Signer(config); } diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index fdb9b1e6e646..6b68f3e2388c 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -212,6 +212,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { } } + await keyStoreManager?.validateSigners(); + // If we are a validator, verify our configuration before doing too much more. if (!config.disableValidator) { if (keyStoreManager === undefined) { diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 2161ea62c933..c5ee508e8b52 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -48,7 +48,17 @@ function test_cmds { echo "$hash:ONLY_TERM_PARENT=1 $run_test_script compose $test" done - echo "$hash:ONLY_TERM_PARENT=1 $run_test_script web3signer src/composed/web3signer/integration_remote_signer.test.ts" + tests=( + src/composed/web3signer/*.test.ts + ) + for test in "${tests[@]}"; do + # We must set ONLY_TERM_PARENT=1 to allow the script to fully control cleanup process. + echo "$hash:ONLY_TERM_PARENT=1 $run_test_script web3signer $test" + done + + #echo "$hash:ONLY_TERM_PARENT=1 $run_test_script simple src/e2e_multi_validator/e2e_multi_validator_node.test.ts" + # echo "$hash:ONLY_TERM_PARENT=1 $run_test_script web3signer src/composed/web3signer/integration_remote_signer.test.ts" + #echo "$hash:ONLY_TERM_PARENT=1 $run_test_script web3signer src/e2e_multi_validator/e2e_multi_validator_node_key_store.test.ts" # TODO(AD): figure out workaround for mainframe subnet exhaustion if [ "$CI" -eq 1 ]; then diff --git a/yarn-project/end-to-end/scripts/web3signer/docker-compose.yml b/yarn-project/end-to-end/scripts/web3signer/docker-compose.yml index d64d298b0c24..e6f83ad373d3 100644 --- a/yarn-project/end-to-end/scripts/web3signer/docker-compose.yml +++ b/yarn-project/end-to-end/scripts/web3signer/docker-compose.yml @@ -1,18 +1,6 @@ -configs: - test_private_key: - content: | - type: file-raw - keyType: SECP256K1 - privateKey: 0x1111111111111111111111111111111111111111111111111111111111111111 - services: web3signer: image: consensys/web3signer:25.6.0 - ports: - - "9000:9000" - configs: - - source: test_private_key - target: /keys/test_private_key.yaml command: - --http-listen-port=9000 - --http-host-allowlist=* @@ -20,6 +8,8 @@ services: - --logging=ALL - eth1 - --chain-id=31337 + volumes: + - web3signer_keys:/keys end-to-end: image: aztecprotocol/build:3.0 @@ -29,6 +19,7 @@ services: volumes: - ../../../../:/root/aztec-packages - ${HOME}/.bb-crs:/root/.bb-crs + - web3signer_keys:/keys tmpfs: - /tmp:rw,size=1g - /tmp-jest:rw,size=512m @@ -38,6 +29,7 @@ services: LOG_LEVEL: ${LOG_LEVEL:-verbose} L1_CHAIN_ID: 31337 WEB3_SIGNER_URL: http://web3signer:9000 + WEB3_SIGNER_TEST_KEYSTORE_DIR: /keys FORCE_COLOR: ${FORCE_COLOR:-1} # Allow git usage despite different ownership. Relevant for script tests. GIT_CONFIG_GLOBAL: /root/aztec-packages/build-images/src/home/.gitconfig @@ -60,8 +52,6 @@ services: # There's a lot of doubling of $'s to escape dockers string interpolation. entrypoint: > bash -c ' - export TEST_PRIVATE_KEY=$(yq .privateKey /keys/test_private_key.yaml) - while ! nc -z web3signer 9000; do sleep 1; done; setsid ./scripts/test_simple.sh ${TEST:-./src/e2e_deploy_contract.test.ts} & pid=$$! @@ -72,7 +62,7 @@ services: ' depends_on: - web3signer - configs: - # mount in the test as well in order to compare remote against local signer - - source: test_private_key - target: /keys/test_private_key.yaml + +volumes: + # a shared volume so that tests can load up arbitrary private keys into web3signer + web3signer_keys: {} diff --git a/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node_key_store.test.ts b/yarn-project/end-to-end/src/composed/web3signer/e2e_multi_validator_node_key_store.test.ts similarity index 90% rename from yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node_key_store.test.ts rename to yarn-project/end-to-end/src/composed/web3signer/e2e_multi_validator_node_key_store.test.ts index f26326325972..833dd7b2c14d 100644 --- a/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node_key_store.test.ts +++ b/yarn-project/end-to-end/src/composed/web3signer/e2e_multi_validator_node_key_store.test.ts @@ -26,31 +26,33 @@ import { NodeKeystoreAdapter, ValidatorClient } from '@aztec/validator-client'; import { jest } from '@jest/globals'; import { mkdtemp, rmdir } from 'fs/promises'; -import { createServer } from 'http'; import { tmpdir } from 'os'; import { join } from 'path'; import { privateKeyToAccount } from 'viem/accounts'; -import { MNEMONIC } from '../fixtures/fixtures.js'; -import { getPrivateKeyFromIndex, setup } from '../fixtures/utils.js'; import { addressForPrivateKey, - createJSONRPCSigner, createKeyFile1, createKeyFile2, createKeyFile3, createKeyFile4, createKeyFile5, createKeyFile6, -} from './utils.js'; +} from '../../e2e_multi_validator/utils.js'; +import { MNEMONIC } from '../../fixtures/fixtures.js'; +import { getPrivateKeyFromIndex, setup } from '../../fixtures/utils.js'; +import { + createWeb3SignerKeystore, + getWeb3SignerTestKeystoreDir, + getWeb3SignerUrl, + refreshWeb3Signer, +} from '../../fixtures/web3signer.js'; const VALIDATOR_COUNT = 7; const COMMITTEE_SIZE = VALIDATOR_COUNT; const PUBLISHER_COUNT = 7; const VALIDATOR_KEY_START_INDEX = 0; const PUBLISHER_KEY_START_INDEX = VALIDATOR_COUNT + VALIDATOR_KEY_START_INDEX; -const SIGNER_URL_PORT = 15000; -const SIGNER_URL = `http://localhost:${SIGNER_URL_PORT}`; const PROVER_PUBLISHER_INDEX = PUBLISHER_KEY_START_INDEX + PUBLISHER_COUNT; const BLOCK_COUNT = 20; @@ -66,6 +68,8 @@ const validators = Array.from( async function createKeyFiles() { const directory = await mkdtemp(join(tmpdir(), 'foo-')); + const web3signerDir = getWeb3SignerTestKeystoreDir(); + const web3signerUrl = getWeb3SignerUrl(); const file1 = join(directory, 'keyfile1.json'); const file2 = join(directory, 'keyfile2.json'); const file3 = join(directory, 'keyfile3.json'); @@ -106,9 +110,11 @@ async function createKeyFiles() { publishers[2].key, publishers[3].key, coinbaseAddresses[1], - SIGNER_URL, + getWeb3SignerUrl(), feeRecipientAddresses[2], ); + await createWeb3SignerKeystore(web3signerDir, validators[2]); + await createKeyFile4( file4, addressForPrivateKey(validators[3]), @@ -119,12 +125,19 @@ async function createKeyFiles() { publishers[6].key, coinbaseAddresses[3], coinbaseAddresses[4], - SIGNER_URL, + web3signerUrl, feeRecipientAddresses[3], feeRecipientAddresses[4], ); - await createKeyFile5(file5, addressForPrivateKey(proverPrivateKey), SIGNER_URL); + await createWeb3SignerKeystore(web3signerDir, validators[3], validators[4]); + + await createKeyFile5(file5, addressForPrivateKey(proverPrivateKey), web3signerUrl); + await createWeb3SignerKeystore(web3signerDir, proverPrivateKey); + await createKeyFile6(file6, MNEMONIC, 5, coinbaseAddresses[5], feeRecipientAddresses[5]); + + await refreshWeb3Signer(web3signerUrl); + return directory; } @@ -157,11 +170,8 @@ describe('e2e_multi_validator_node', () => { let sequencerClient: SequencerClient | undefined; let publisherFactory: SequencerPublisherFactory; let validatorClient: ValidatorClient; - let jsonRpcServer: ReturnType | null = null; const artifact = StatefulTestContractArtifact; const addressToPrivateKey = new Map(); - const remoteSignerStats = new Map(); - const expectedRemoteSigners = new Set(); const expectedCoinbaseAddresses = new Map(); const expectedFeeRecipientAddresses = new Map(); const expectedPublishers = new Map(); @@ -185,11 +195,6 @@ describe('e2e_multi_validator_node', () => { }; }); - // These validators have remote signing configured - expectedRemoteSigners.add(validatorAddresses[2].toLowerCase()); - expectedRemoteSigners.add(validatorAddresses[3].toLowerCase()); - expectedRemoteSigners.add(validatorAddresses[4].toLowerCase()); - // Setup expected coinbase and fee recipient values per validator validatorAddresses.forEach((validatorAddress, i) => { const coinbase = EthAddress.fromNumber(i + 1) @@ -268,13 +273,6 @@ describe('e2e_multi_validator_node', () => { addressToPrivateKey.set(account.toLowerCase(), pk); } - // Create JSON RPC server for signing transactions - jsonRpcServer = createJSONRPCSigner(addressToPrivateKey, remoteSignerStats); - // Start server on the SIGNER_URL port - await new Promise(resolve => { - jsonRpcServer!.listen(SIGNER_URL_PORT, resolve); - }); - const { aztecSlotDuration: _aztecSlotDuration } = getL1ContractsConfigEnvVars(); ({ @@ -322,13 +320,6 @@ describe('e2e_multi_validator_node', () => { afterEach(async () => { await teardown(); await rmdir(keyStoreDirectory, { recursive: true }); - - // Close JSON RPC server - if (jsonRpcServer) { - await new Promise(resolve => { - jsonRpcServer!.close(() => resolve()); - }); - } }); const sendTx = async (sender: AztecAddress, contractAddressSalt: Fr) => { @@ -411,14 +402,6 @@ describe('e2e_multi_validator_node', () => { }), ); - const currentBlockNumber = await aztecNode.getBlockNumber(); - - for (const expectedRemoteSigner of expectedRemoteSigners) { - const remoteSigner = remoteSignerStats.get(expectedRemoteSigner); - expect(remoteSigner).toBeDefined(); - expect(remoteSigner).toBeGreaterThanOrEqual(currentBlockNumber); - } - for (const [proposer, coinbase] of requestedCoinbaseAddresses) { const expectedCoinbase = expectedCoinbaseAddresses.get(proposer); expect(expectedCoinbase).toBeDefined(); diff --git a/yarn-project/end-to-end/src/composed/web3signer/integration_remote_signer.test.ts b/yarn-project/end-to-end/src/composed/web3signer/integration_remote_signer.test.ts index afd5016d612a..4e7088ba3522 100644 --- a/yarn-project/end-to-end/src/composed/web3signer/integration_remote_signer.test.ts +++ b/yarn-project/end-to-end/src/composed/web3signer/integration_remote_signer.test.ts @@ -4,19 +4,22 @@ import { LocalSigner, RemoteSigner } from '@aztec/node-keystore'; import { jest } from '@jest/globals'; import type { TransactionSerializable, TypedDataDefinition } from 'viem'; -import { privateKeyToAddress } from 'viem/accounts'; +import { generatePrivateKey, privateKeyToAddress } from 'viem/accounts'; -const { - WEB3_SIGNER_URL = 'http://localhost:9000', - L1_CHAIN_ID = '31337', - TEST_PRIVATE_KEY = '0x1111111111111111111111111111111111111111111111111111111111111111', -} = process.env; +import { + createWeb3SignerKeystore, + getWeb3SignerTestKeystoreDir, + getWeb3SignerUrl, + refreshWeb3Signer, +} from '../../fixtures/web3signer.js'; + +const { L1_CHAIN_ID = '31337' } = process.env; describe('RemoteSigner integration: Web3Signer (compose)', () => { jest.setTimeout(180_000); - let chainId: number; let web3SignerUrl: string; + let chainId: number; let privateKey: Buffer32; let address: EthAddress; @@ -24,20 +27,16 @@ describe('RemoteSigner integration: Web3Signer (compose)', () => { let remoteSigner: RemoteSigner; let localSigner: LocalSigner; - beforeAll(() => { - if (!WEB3_SIGNER_URL) { - throw new Error('Need to set WEB3_SIGNER_URL'); - } + beforeAll(async () => { + web3SignerUrl = getWeb3SignerUrl(); - if (!TEST_PRIVATE_KEY) { - throw new Error('Need to set WEB3_SIGNER_URL'); - } - - privateKey = Buffer32.fromString(TEST_PRIVATE_KEY); + privateKey = Buffer32.fromString(generatePrivateKey()); address = EthAddress.fromString(privateKeyToAddress(privateKey.toString())); chainId = parseInt(L1_CHAIN_ID, 10); - web3SignerUrl = WEB3_SIGNER_URL; + + await createWeb3SignerKeystore(getWeb3SignerTestKeystoreDir(), privateKey.toString()); + await refreshWeb3Signer(web3SignerUrl); }); beforeEach(() => { @@ -138,4 +137,21 @@ describe('RemoteSigner integration: Web3Signer (compose)', () => { expect(remoteSig.s.toString()).toBe(localSig.s.toString()); expect([0, 1, 27, 28]).toContain(remoteSig.v); }); + + it('validates web3signer accessibility and address availability', async () => { + // Should succeed with the correct address + await expect(RemoteSigner.validateAccess(web3SignerUrl, [address.toString()])).resolves.not.toThrow(); + + // Should fail with a non-existent address + const nonExistentAddress = EthAddress.random().toString(); + await expect(RemoteSigner.validateAccess(web3SignerUrl, [nonExistentAddress])).rejects.toThrow( + `The following addresses are not available in the web3signer: ${nonExistentAddress.toLowerCase()}`, + ); + + // Should succeed when checking multiple addresses where one exists + await expect(RemoteSigner.validateAccess(web3SignerUrl, [address.toString()])).resolves.not.toThrow(); + + // Should fail with an invalid URL + await expect(RemoteSigner.validateAccess('http://invalid-url:9999', [address.toString()])).rejects.toThrow(); + }); }); diff --git a/yarn-project/end-to-end/src/fixtures/web3signer.ts b/yarn-project/end-to-end/src/fixtures/web3signer.ts new file mode 100644 index 000000000000..ac81895fc67a --- /dev/null +++ b/yarn-project/end-to-end/src/fixtures/web3signer.ts @@ -0,0 +1,46 @@ +import { sleep } from '@aztec/aztec.js'; +import { randomBytes } from '@aztec/foundation/crypto'; + +import { mkdirSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export async function createWeb3SignerKeystore(dir: string, ...privateKeys: string[]) { + const yaml = privateKeys + .map( + pk => `\ +type: file-raw +keyType: SECP256K1 +privateKey: ${pk}`, + ) + .join('\n---\n'); + + // NOTE: nodejs stdlib can only create temp directories, not temp files! + // this write uses wx (write-exclusive) so it'll throw if the file already exists + const path = join(dir, `keystore-${randomBytes(4).toString('hex')}.yaml`); + await writeFile(path, yaml, { flag: 'wx' }); +} + +export async function refreshWeb3Signer(url: string) { + await fetch(new URL('reload', url), { method: 'POST' }); + // give the service a chance to load up the new files + // 1s might not be enough if there are a lot of files to scan + await sleep(1000); +} + +export function getWeb3SignerTestKeystoreDir(): string { + if (process.env.WEB3_SIGNER_TEST_KEYSTORE_DIR) { + mkdirSync(process.env.WEB3_SIGNER_TEST_KEYSTORE_DIR, { recursive: true }); + return process.env.WEB3_SIGNER_TEST_KEYSTORE_DIR; + } else { + throw new Error('Web3signer not running'); + } +} + +export function getWeb3SignerUrl(): string { + if (process.env.WEB3_SIGNER_URL) { + return process.env.WEB3_SIGNER_URL; + } else { + throw new Error('Web3signer not running'); + } +} diff --git a/yarn-project/node-keystore/src/keystore_manager.test.ts b/yarn-project/node-keystore/src/keystore_manager.test.ts index 48c47da1242b..b2b260be3fd5 100644 --- a/yarn-project/node-keystore/src/keystore_manager.test.ts +++ b/yarn-project/node-keystore/src/keystore_manager.test.ts @@ -6,13 +6,14 @@ import { Buffer32 } from '@aztec/foundation/buffer'; import { EthAddress } from '@aztec/foundation/eth-address'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { describe, expect, it } from '@jest/globals'; +import { describe, expect, it, jest } from '@jest/globals'; import { mkdirSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { mnemonicToAccount } from 'viem/accounts'; import { KeystoreError, KeystoreManager } from '../src/keystore_manager.js'; +import { RemoteSigner } from '../src/signer.js'; import type { KeyStore } from '../src/types.js'; describe('KeystoreManager', () => { @@ -1093,4 +1094,216 @@ describe('KeystoreManager', () => { expect(cfg).toBeUndefined(); }); }); + + describe('validateSigners', () => { + it('should not validate when there are no remote signers', async () => { + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as any, + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + }); + + it('should validate remote signers for validators', async () => { + const testAddress = EthAddress.random(); + const testUrl = 'http://test-signer:9000'; + + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: { address: testAddress, remoteSignerUrl: testUrl }, + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + using _ = jest.spyOn(RemoteSigner, 'validateAccess').mockImplementation(() => Promise.resolve()); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + }); + + it('should batch validate multiple addresses for the same remote signer URL', async () => { + const testUrl = 'http://test-signer:9000'; + const address1 = EthAddress.random(); + const address2 = EthAddress.random(); + const address3 = EthAddress.random(); + + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: [ + { address: address1, remoteSignerUrl: testUrl }, + { address: address2, remoteSignerUrl: testUrl }, + ], + publisher: { address: address3, remoteSignerUrl: testUrl }, + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess').mockImplementation(() => Promise.resolve()); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + + // Should batch all three addresses into one call + expect(validateAccessSpy).toHaveBeenCalledTimes(1); + expect(validateAccessSpy).toHaveBeenCalledWith( + testUrl, + expect.arrayContaining([address1.toString(), address2.toString(), address3.toString()]), + ); + }); + + it('should validate remote signers from default config', async () => { + const defaultUrl = 'http://default-signer:9000'; + const address = EthAddress.random(); + + const keystore: KeyStore = { + schemaVersion: 1, + remoteSigner: defaultUrl, + validators: [ + { + attester: address, // Just address, uses default remote signer + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); + validateAccessSpy.mockResolvedValueOnce(undefined); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + + expect(validateAccessSpy).toHaveBeenCalledWith(defaultUrl, [address.toString()]); + }); + + it('should validate slasher remote signers', async () => { + const testUrl = 'http://slasher-signer:9000'; + const slasherAddress = EthAddress.random(); + + const keystore: KeyStore = { + schemaVersion: 1, + slasher: { address: slasherAddress, remoteSignerUrl: testUrl }, + }; + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); + validateAccessSpy.mockResolvedValueOnce(undefined); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + + expect(validateAccessSpy).toHaveBeenCalledWith(testUrl, [slasherAddress.toString()]); + }); + + it('should validate prover remote signers', async () => { + const testUrl = 'http://prover-signer:9000'; + const publisherAddress = EthAddress.random(); + const proverId = EthAddress.random(); + + const keystore: KeyStore = { + schemaVersion: 1, + remoteSigner: testUrl, + prover: { + id: proverId, + publisher: [publisherAddress], + }, + }; + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); + validateAccessSpy.mockResolvedValueOnce(undefined); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + + expect(validateAccessSpy).toHaveBeenCalledWith(testUrl, [publisherAddress.toString()]); + }); + + it('should handle validation errors', async () => { + const testUrl = 'http://test-signer:9000'; + const address = EthAddress.random(); + + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: { address, remoteSignerUrl: testUrl }, + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); + validateAccessSpy.mockRejectedValueOnce(new Error('Connection refused')); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).rejects.toThrow('Connection refused'); + }); + + it('should skip validation for mnemonic and JSON V3 configs', async () => { + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: { mnemonic: 'test test test test test test test test test test test junk' } as any, + feeRecipient: await AztecAddress.random(), + }, + { + attester: { path: '/some/path.json', password: 'test' } as any, + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + + // Should not call validateAccess for mnemonic or JSON configs + expect(validateAccessSpy).not.toHaveBeenCalled(); + }); + + it('should validate multiple remote signer URLs separately', async () => { + const url1 = 'http://signer1:9000'; + const url2 = 'http://signer2:9000'; + const address1 = EthAddress.random(); + const address2 = EthAddress.random(); + + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: { address: address1, remoteSignerUrl: url1 }, + feeRecipient: await AztecAddress.random(), + }, + { + attester: { address: address2, remoteSignerUrl: url2 }, + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); + validateAccessSpy.mockResolvedValue(undefined); + + const manager = new KeystoreManager(keystore); + await expect(manager.validateSigners()).resolves.not.toThrow(); + + // Should call validateAccess twice, once for each URL + expect(validateAccessSpy).toHaveBeenCalledTimes(2); + expect(validateAccessSpy).toHaveBeenCalledWith(url1, [address1.toString()]); + expect(validateAccessSpy).toHaveBeenCalledWith(url2, [address2.toString()]); + }); + }); }); diff --git a/yarn-project/node-keystore/src/keystore_manager.ts b/yarn-project/node-keystore/src/keystore_manager.ts index 5e1cc29443a7..47b08576953a 100644 --- a/yarn-project/node-keystore/src/keystore_manager.ts +++ b/yarn-project/node-keystore/src/keystore_manager.ts @@ -58,6 +58,82 @@ export class KeystoreManager { this.validateUniqueAttesterAddresses(); } + /** + * Validates all remote signers in the keystore are accessible and have the required addresses. + * Should be called after construction if validation is needed. + */ + async validateSigners(): Promise { + // Collect all remote signers with their addresses grouped by URL + const remoteSignersByUrl = new Map>(); + + // Helper to extract remote signer URL from config + const getUrl = (config: EthRemoteSignerConfig): string => { + return typeof config === 'string' ? config : config.remoteSignerUrl; + }; + + // Helper to collect remote signers from accounts + const collectRemoteSigners = (accounts: EthAccounts, defaultRemoteSigner?: EthRemoteSignerConfig): void => { + const processAccount = (account: EthAccount): void => { + if (typeof account === 'object' && !('path' in account) && !('mnemonic' in (account as any))) { + // This is a remote signer account + const remoteSigner = account as EthRemoteSignerAccount; + const address = 'address' in remoteSigner ? remoteSigner.address : remoteSigner; + + let url: string; + if ('remoteSignerUrl' in remoteSigner && remoteSigner.remoteSignerUrl) { + url = remoteSigner.remoteSignerUrl; + } else if (defaultRemoteSigner) { + url = getUrl(defaultRemoteSigner); + } else { + return; // No remote signer URL available + } + + if (!remoteSignersByUrl.has(url)) { + remoteSignersByUrl.set(url, new Set()); + } + remoteSignersByUrl.get(url)!.add(address.toString()); + } + }; + + if (Array.isArray(accounts)) { + accounts.forEach(account => collectRemoteSigners(account, defaultRemoteSigner)); + } else if (typeof accounts === 'object' && 'mnemonic' in accounts) { + // Skip mnemonic configs + } else { + processAccount(accounts as EthAccount); + } + }; + + // Collect from validators + const validatorCount = this.getValidatorCount(); + for (let i = 0; i < validatorCount; i++) { + const validator = this.getValidator(i); + const remoteSigner = validator.remoteSigner || this.keystore.remoteSigner; + + collectRemoteSigners(validator.attester, remoteSigner); + if (validator.publisher) { + collectRemoteSigners(validator.publisher, remoteSigner); + } + } + + // Collect from slasher + if (this.keystore.slasher) { + collectRemoteSigners(this.keystore.slasher, this.keystore.remoteSigner); + } + + // Collect from prover + if (this.keystore.prover && typeof this.keystore.prover === 'object' && 'publisher' in this.keystore.prover) { + collectRemoteSigners(this.keystore.prover.publisher, this.keystore.remoteSigner); + } + + // Validate each remote signer URL with all its addresses + for (const [url, addresses] of remoteSignersByUrl.entries()) { + if (addresses.size > 0) { + await RemoteSigner.validateAccess(url, Array.from(addresses)); + } + } + } + /** * Validates that attester addresses are unique across all validators * Only checks simple private key attesters, not JSON-V3 or mnemonic attesters, diff --git a/yarn-project/node-keystore/src/signer.ts b/yarn-project/node-keystore/src/signer.ts index 04807561d33b..5201b1db66e7 100644 --- a/yarn-project/node-keystore/src/signer.ts +++ b/yarn-project/node-keystore/src/signer.ts @@ -104,6 +104,82 @@ export class RemoteSigner implements EthSigner { private fetch: typeof globalThis.fetch = globalThis.fetch, ) {} + /** + * Validates that a web3signer is accessible and that the given addresses are available. + * @param remoteSignerUrl - The URL of the web3signer (can be string or EthRemoteSignerConfig) + * @param addresses - The addresses to check for availability + * @param fetch - Optional fetch implementation for testing + * @throws Error if the web3signer is not accessible or if any address is not available + */ + static async validateAccess( + remoteSignerUrl: EthRemoteSignerConfig, + addresses: string[], + fetch: typeof globalThis.fetch = globalThis.fetch, + ): Promise { + const url = typeof remoteSignerUrl === 'string' ? remoteSignerUrl : remoteSignerUrl.remoteSignerUrl; + + try { + // Check if the web3signer is reachable by calling eth_accounts + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_accounts', + params: [], + id: 1, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new SignerError( + `Web3Signer validation failed: ${response.status} ${response.statusText} - ${errorText}`, + 'eth_accounts', + url, + response.status, + ); + } + + const result = await response.json(); + + if (result.error) { + throw new SignerError( + `Web3Signer JSON-RPC error during validation: ${result.error.code} - ${result.error.message}`, + 'eth_accounts', + url, + 200, + result.error.code, + ); + } + + if (!result.result || !Array.isArray(result.result)) { + throw new Error('Invalid response from Web3Signer: expected array of accounts'); + } + + // Normalize addresses to lowercase for comparison + const availableAccounts: string[] = result.result.map((addr: string) => addr.toLowerCase()); + const requestedAddresses = addresses.map(addr => addr.toLowerCase()); + + // Check if all requested addresses are available + const missingAddresses = requestedAddresses.filter(addr => !availableAccounts.includes(addr)); + + if (missingAddresses.length > 0) { + throw new Error(`The following addresses are not available in the web3signer: ${missingAddresses.join(', ')}`); + } + } catch (error: any) { + if (error instanceof SignerError) { + throw error; + } + if (error.code === 'ECONNREFUSED' || error.cause?.code === 'ECONNREFUSED') { + throw new Error(`Unable to connect to web3signer at ${url}. Please ensure it is running and accessible.`); + } + throw error; + } + } + /** * Sign a message using eth_sign via remote JSON-RPC. */ diff --git a/yarn-project/prover-node/src/config.ts b/yarn-project/prover-node/src/config.ts index c2ff0a9d9be6..b4d54cfe644d 100644 --- a/yarn-project/prover-node/src/config.ts +++ b/yarn-project/prover-node/src/config.ts @@ -118,7 +118,7 @@ export function getProverNodeAgentConfigFromEnv(): ProverAgentConfig & BBConfig }; } -function createKeyStoreFromWeb3Signer(config: ProverNodeConfig) { +function createKeyStoreFromWeb3Signer(config: ProverNodeConfig): KeyStore | undefined { // If we don't have a valid prover Id then we can't build a valid key store with remote signers if (config.proverId === undefined) { return undefined; @@ -144,7 +144,7 @@ function createKeyStoreFromWeb3Signer(config: ProverNodeConfig) { return keyStore; } -function createKeyStoreFromPublisherKeys(config: ProverNodeConfig) { +function createKeyStoreFromPublisherKeys(config: ProverNodeConfig): KeyStore | undefined { // Extract the publisher keys from the provided config. const publisherKeys = config.publisherPrivateKeys ? config.publisherPrivateKeys.map(k => ethPrivateKeySchema.parse(k.getValue())) @@ -174,7 +174,7 @@ function createKeyStoreFromPublisherKeys(config: ProverNodeConfig) { return keyStore; } -export function createKeyStoreForProver(config: ProverNodeConfig) { +export function createKeyStoreForProver(config: ProverNodeConfig): KeyStore | undefined { if (config.web3SignerUrl !== undefined && config.web3SignerUrl.length > 0) { return createKeyStoreFromWeb3Signer(config); } diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index 10e8998954f2..ea3a5cb1549e 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -73,6 +73,8 @@ export async function createProverNode( } } + await keyStoreManager?.validateSigners(); + // Extract the prover signers from the key store and verify that we have one. const proverSigners = keyStoreManager?.createProverSigners();