diff --git a/yarn-project/cli/src/cmds/validator_keys/index.ts b/yarn-project/cli/src/cmds/validator_keys/index.ts index 8588f40c55a..0c6088a6cbf 100644 --- a/yarn-project/cli/src/cmds/validator_keys/index.ts +++ b/yarn-project/cli/src/cmds/validator_keys/index.ts @@ -33,8 +33,10 @@ export function injectCommands(program: Command, log: LogFn) { 'Coinbase ETH address to use when proposing. Defaults to attester address.', parseEthereumAddress, ) - // TODO: add funding account back in when implemented - // .option('--funding-account ', 'ETH private key (or address for remote signer setup) to fund publishers') + .option( + '--funding-account ', + 'ETH funding account used to top up publisher EOAs. Provide a private key, or an address together with --remote-signer.', + ) .option('--remote-signer ', 'Default remote signer URL for accounts in this file') .option('--ikm ', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32)) .option('--bls-path ', `EIP-2334 path (default ${defaultBlsPath})`) @@ -99,8 +101,6 @@ export function injectCommands(program: Command, log: LogFn) { 'Coinbase ETH address to use when proposing. Defaults to attester address.', parseEthereumAddress, ) - // TODO: add funding account back in when implemented - // .option('--funding-account ', 'ETH private key (or address for remote signer setup) to fund publishers') .option('--remote-signer ', 'Default remote signer URL for accounts in this file') .option('--ikm ', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32)) .option('--bls-path ', `EIP-2334 path (default ${defaultBlsPath})`) @@ -117,6 +117,32 @@ export function injectCommands(program: Command, log: LogFn) { await addValidatorKeys(existing, options, log); }); + group + .command('set-funding-account') + .summary('Set the funding account of an existing keystore') + .description( + 'Sets the keystore-level ETH funding account used to top up publisher EOAs, replacing any existing one', + ) + .argument('', 'Path to existing keystore JSON') + .argument( + '', + 'Funding account: a private key, or an address (needs --remote-signer unless the keystore already defines one)', + ) + .option( + '--remote-signer ', + 'Remote signer URL for the funding account (required with an address unless the keystore already defines one)', + ) + .option( + '--password ', + 'Password for writing the funding key as an encrypted ETH JSON V3 file. Empty string allowed', + ) + .option('--encrypted-keystore-dir ', 'Output directory for the encrypted funding key file') + .option('--json', 'Echo resulting JSON to stdout') + .action(async (existing: string, fundingAccount: string, options) => { + const { setFundingAccount } = await import('./set_funding_account.js'); + await setFundingAccount(existing, fundingAccount, options, log); + }); + group .command('staker') .summary('Generate staking JSON from keystore') diff --git a/yarn-project/cli/src/cmds/validator_keys/new.ts b/yarn-project/cli/src/cmds/validator_keys/new.ts index 208ebc7a874..bc8ebc9826b 100644 --- a/yarn-project/cli/src/cmds/validator_keys/new.ts +++ b/yarn-project/cli/src/cmds/validator_keys/new.ts @@ -9,12 +9,14 @@ import { wordlist } from '@scure/bip39/wordlists/english.js'; import { readFile, writeFile } from 'fs/promises'; import { basename, dirname, join } from 'path'; import { createPublicClient, fallback, http } from 'viem'; -import { generateMnemonic, mnemonicToAccount } from 'viem/accounts'; +import { generateMnemonic, mnemonicToAccount, privateKeyToAccount } from 'viem/accounts'; import { buildValidatorEntries, + encryptFundingAccountToFile, logValidatorSummaries, maybePrintJson, + resolveFundingAccount, resolveKeystoreOutputPath, writeBlsBn254ToFile, writeEthJsonV3ToFile, @@ -23,6 +25,7 @@ import { import { processAttesterAccounts } from './staker.js'; import { validateBlsPathOptions, + validateFundingAccountOptions, validatePublisherOptions, validateRemoteSignerOptions, validateStakerOutputOptions, @@ -51,6 +54,7 @@ export type NewValidatorKeystoreOptions = { json?: boolean; feeRecipient: AztecAddress; coinbase?: EthAddress; + fundingAccount?: string; remoteSigner?: string; stakerOutput?: boolean; gseAddress?: EthAddress; @@ -147,6 +151,8 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, validatePublisherOptions(options); // validate remote signer options validateRemoteSignerOptions(options); + // validate funding account option + validateFundingAccountOptions(options); const { dataDir, @@ -156,6 +162,7 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, publishers, json, coinbase, + fundingAccount, accountIndex = 0, addressIndex = 0, feeRecipient, @@ -213,17 +220,26 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, remoteSigner, }); + let resolvedFundingAccount = fundingAccount ? resolveFundingAccount(fundingAccount, remoteSigner) : undefined; + // If password provided, write ETH JSON V3 and BLS BN254 keystores and replace plaintext if (shouldEncryptKeystores) { const encryptedKeystoreOutDir = encryptedKeystoreDir && encryptedKeystoreDir.length > 0 ? encryptedKeystoreDir : keystoreOutDir; await writeEthJsonV3ToFile(validators, { outDir: encryptedKeystoreOutDir, password: ethPassword }); await writeBlsBn254ToFile(validators, { outDir: encryptedKeystoreOutDir, password: blsPassword }); + if (resolvedFundingAccount) { + resolvedFundingAccount = await encryptFundingAccountToFile(resolvedFundingAccount, { + outDir: encryptedKeystoreOutDir, + password: ethPassword, + }); + } } const keystore = { schemaVersion: 1, validators, + ...(resolvedFundingAccount ? { fundingAccount: resolvedFundingAccount } : {}), }; await writeKeystoreFile(outputPath, keystore); @@ -285,6 +301,11 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, // print a concise summary of public keys (addresses and BLS pubkeys) if no --json options was selected if (!json) { logValidatorSummaries(log, summaries); + if (fundingAccount) { + const funderAddress = + fundingAccount.length === 66 ? privateKeyToAccount(fundingAccount as `0x${string}`).address : fundingAccount; + log(`funding account: ${funderAddress}`); + } } if (mnemonic && remoteSigner && !json) { diff --git a/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts b/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts new file mode 100644 index 00000000000..21258abb56b --- /dev/null +++ b/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts @@ -0,0 +1,55 @@ +import type { LogFn } from '@aztec/foundation/log'; +import { loadKeystoreFile } from '@aztec/node-keystore/loader'; +import type { KeyStore } from '@aztec/node-keystore/types'; + +import { dirname } from 'path'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { encryptFundingAccountToFile, maybePrintJson, resolveFundingAccount, writeKeystoreFile } from './shared.js'; +import { validateFundingAccountOptions } from './utils.js'; + +export type SetFundingAccountOptions = { + remoteSigner?: string; + password?: string; + encryptedKeystoreDir?: string; + json?: boolean; +}; + +/** + * Sets the top-level funding account of an existing keystore, replacing any previous one. The + * account may be a private key, or an address paired with a remote signer URL. With a password, + * a plaintext key is encrypted to an ETH JSON V3 file and stored as a { path, password } reference. + */ +export async function setFundingAccount( + existing: string, + fundingAccount: string, + options: SetFundingAccountOptions, + log: LogFn, +) { + const { remoteSigner, password, encryptedKeystoreDir, json } = options; + + const keystore: KeyStore = loadKeystoreFile(existing); + + const validated = { fundingAccount, remoteSigner }; + validateFundingAccountOptions(validated, !!keystore.remoteSigner); + + let resolved = resolveFundingAccount(validated.fundingAccount!, remoteSigner); + if (password !== undefined) { + const outDir = encryptedKeystoreDir && encryptedKeystoreDir.length > 0 ? encryptedKeystoreDir : dirname(existing); + resolved = await encryptFundingAccountToFile(resolved, { outDir, password }); + } + + if (keystore.fundingAccount) { + log('Replacing existing funding account in keystore'); + } + keystore.fundingAccount = resolved; + + await writeKeystoreFile(existing, keystore); + + if (!json) { + const value = validated.fundingAccount!; + const funderAddress = value.length === 66 ? privateKeyToAccount(value as `0x${string}`).address : value; + log(`Set funding account ${funderAddress} in ${existing}`); + } + maybePrintJson(log, !!json, keystore as unknown as Record); +} diff --git a/yarn-project/cli/src/cmds/validator_keys/shared.ts b/yarn-project/cli/src/cmds/validator_keys/shared.ts index 981acc155e1..64d16245450 100644 --- a/yarn-project/cli/src/cmds/validator_keys/shared.ts +++ b/yarn-project/cli/src/cmds/validator_keys/shared.ts @@ -3,7 +3,7 @@ import { asyncPool } from '@aztec/foundation/async-pool'; import { deriveBlsPrivateKey } from '@aztec/foundation/crypto/bls'; import { createBn254Keystore } from '@aztec/foundation/crypto/bls/bn254_keystore'; import { computeBn254G1PublicKeyCompressed } from '@aztec/foundation/crypto/bn254'; -import type { EthAddress } from '@aztec/foundation/eth-address'; +import { EthAddress } from '@aztec/foundation/eth-address'; import type { LogFn } from '@aztec/foundation/log'; import type { EthAccount, EthPrivateKey, ValidatorKeyStore } from '@aztec/node-keystore/types'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -125,6 +125,21 @@ export function deriveEthAttester( : (('0x' + Buffer.from(acct.getHdKey().privateKey!).toString('hex')) as EthPrivateKey); } +/** + * Resolve a `--funding-account` value into a keystore `EthAccount`. A 66-char value is a private key + * (used verbatim). A 42-char value is an address: with an explicit `remoteSigner` URL it becomes an + * `{ address, remoteSignerUrl }` pair; without one it is stored as a bare address that falls back to + * the keystore-level remote signer at runtime. Callers must validate the value first (see + * `validateFundingAccountOptions`). + */ +export function resolveFundingAccount(fundingAccount: string, remoteSigner?: string): EthAccount { + if (fundingAccount.length === 66) { + return fundingAccount as EthPrivateKey; + } + const address = EthAddress.fromString(fundingAccount); + return remoteSigner ? ({ address, remoteSignerUrl: remoteSigner } as EthAccount) : address; +} + export async function buildValidatorEntries(input: BuildValidatorsInput) { const { validatorCount, @@ -330,6 +345,27 @@ export async function writeEthJsonV3Keystore( return outPath; } +/** + * If `account` is a plaintext ETH private key, encrypt it to a JSON V3 file and return a + * { path, password } reference; otherwise return it unchanged. + */ +async function maybeEncryptEthAccount(account: any, label: string, options: { outDir: string; password: string }) { + if (typeof account === 'string' && account.startsWith('0x') && account.length === 66) { + const fileBase = `${label}_${account.slice(2, 10)}`; + const p = await writeEthJsonV3Keystore(options.outDir, fileBase, options.password, account); + return { path: p, password: options.password }; + } + return account; +} + +/** Encrypt a plaintext funding-account key to a JSON V3 file, replacing it with a { path, password } reference. */ +export async function encryptFundingAccountToFile( + account: EthAccount, + options: { outDir: string; password: string }, +): Promise { + return (await maybeEncryptEthAccount(account, 'funding', options)) as EthAccount; +} + /** Replace plaintext ETH keys in validators with { path, password } pointing to JSON V3 files. */ export async function writeEthJsonV3ToFile( validators: ValidatorKeyStore[], diff --git a/yarn-project/cli/src/cmds/validator_keys/utils.ts b/yarn-project/cli/src/cmds/validator_keys/utils.ts index 8b8dca71f19..1e83594f9b6 100644 --- a/yarn-project/cli/src/cmds/validator_keys/utils.ts +++ b/yarn-project/cli/src/cmds/validator_keys/utils.ts @@ -1,4 +1,4 @@ -import type { EthAddress } from '@aztec/foundation/eth-address'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { ethPrivateKeySchema } from '@aztec/node-keystore/schemas'; import type { EthPrivateKey } from '@aztec/node-keystore/types'; @@ -79,3 +79,46 @@ export function validatePublisherOptions(options: { publishers?: string[]; publi options.publishers = normalizedKeys as EthPrivateKey[]; } } + +/** + * Validates and normalizes the `--funding-account` option in place. The value may be a private key + * (used as a local signer) or an ETH address. An address needs a remote signer to sign funding txs: + * either `--remote-signer`, or a keystore that already defines one (pass `hasKeystoreRemoteSigner`), + * which a bare address inherits at runtime. + */ +export function validateFundingAccountOptions( + options: { fundingAccount?: string; remoteSigner?: string }, + hasKeystoreRemoteSigner = false, +) { + if (!options.fundingAccount) { + return; + } + + let value = options.fundingAccount.trim(); + if (!value.startsWith('0x')) { + value = '0x' + value; + } + + if (value.length === 66) { + try { + ethPrivateKeySchema.parse(value); + } catch (error) { + throw new Error(`Invalid funding account private key: ${error instanceof Error ? error.message : String(error)}`); + } + } else if (value.length === 42) { + try { + EthAddress.fromString(value); + } catch (error) { + throw new Error(`Invalid funding account address: ${error instanceof Error ? error.message : String(error)}`); + } + if (!options.remoteSigner && !hasKeystoreRemoteSigner) { + throw new Error( + '--funding-account as an address requires --remote-signer, or a keystore that already defines a remote signer', + ); + } + } else { + throw new Error('Invalid funding account: expected a 32-byte private key or a 20-byte address'); + } + + options.fundingAccount = value; +} diff --git a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts index 24abb2c0616..e647115f759 100644 --- a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts +++ b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts @@ -13,6 +13,7 @@ import { mnemonicToAccount } from 'viem/accounts'; import { addValidatorKeys } from './add.js'; import { generateBlsKeypair } from './generate_bls_keypair.js'; import { newValidatorKeystore } from './new.js'; +import { setFundingAccount } from './set_funding_account.js'; import { buildValidatorEntries, computeBlsPublicKeyCompressed, @@ -24,7 +25,7 @@ import { writeEthJsonV3ToFile, writeKeystoreFile, } from './shared.js'; -import { validatePublisherOptions } from './utils.js'; +import { validateFundingAccountOptions, validatePublisherOptions } from './utils.js'; const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; @@ -402,6 +403,44 @@ describe('validator keys utilities', () => { }); }); + describe('validateFundingAccountOptions', () => { + const validPrivateKey = '0x' + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const validAddress = '0x' + '01'.repeat(20); + + it('accepts a private key and leaves it normalized', () => { + const options = { fundingAccount: validPrivateKey }; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + expect(options.fundingAccount).toBe(validPrivateKey); + }); + + it('adds a missing 0x prefix', () => { + const options = { fundingAccount: validPrivateKey.slice(2) }; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + expect(options.fundingAccount).toBe(validPrivateKey); + }); + + it('accepts an address when a remote signer is set', () => { + const options = { fundingAccount: validAddress, remoteSigner: 'http://localhost:9000' }; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + expect(options.fundingAccount).toBe(validAddress); + }); + + it('throws for an address without a remote signer', () => { + const options = { fundingAccount: validAddress }; + expect(() => validateFundingAccountOptions(options)).toThrow(/requires --remote-signer/); + }); + + it('throws for a malformed value', () => { + const options = { fundingAccount: '0x1234' }; + expect(() => validateFundingAccountOptions(options)).toThrow(/Invalid funding account/); + }); + + it('is a no-op when unset', () => { + const options = {}; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + }); + }); + describe('newValidatorKeystore', () => { it('creates a keystore file and logs a summary', async () => { const path = join(tmp, 'created.json'); @@ -946,6 +985,99 @@ describe('validator keys utilities', () => { expect(Array.isArray(validator.publisher)).toBe(true); expect(validator.publisher).toEqual([publisherKey1, publisherKey2]); }); + + it('writes a top-level funding account from a private key', async () => { + const path = join(tmp, 'with-funding-key.json'); + const fundingKey = '0x' + 'ab'.repeat(32); + await newValidatorKeystore( + { + dataDir: tmp, + file: 'with-funding-key.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: fundingKey, + feeRecipient: ('0x' + '11'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ); + const keystore: KeyStore = loadKeystoreFile(path); + expect(keystore.fundingAccount).toBe(fundingKey); + }); + + it('writes a remote-signer funding account from an address', async () => { + const path = join(tmp, 'with-funding-address.json'); + const fundingAddress = '0x' + '02'.repeat(20); + const remoteSigner = 'http://localhost:9000'; + await newValidatorKeystore( + { + dataDir: tmp, + file: 'with-funding-address.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: fundingAddress, + remoteSigner, + feeRecipient: ('0x' + '12'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ); + const keystore: KeyStore = loadKeystoreFile(path); + const funder = keystore.fundingAccount as any; + expect(funder.remoteSignerUrl).toBe(remoteSigner); + expect(funder.address.toString().toLowerCase()).toBe(fundingAddress); + }); + + it('rejects a funding-account address without a remote signer', async () => { + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'funding-address-no-signer.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: '0x' + '03'.repeat(20), + feeRecipient: ('0x' + '13'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ), + ).rejects.toThrow(/requires --remote-signer/); + }); + + it('rejects a malformed funding account', async () => { + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'funding-malformed.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: '0xdead', + feeRecipient: ('0x' + '14'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ), + ).rejects.toThrow(/Invalid funding account/); + }); + + it('encrypts a plaintext funding account when a password is provided', async () => { + const path = join(tmp, 'with-funding-encrypted.json'); + await newValidatorKeystore( + { + dataDir: tmp, + file: 'with-funding-encrypted.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: '0x' + 'cd'.repeat(32), + password: 'funding-test-pw', + encryptedKeystoreDir: tmp, + feeRecipient: ('0x' + '15'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ); + const keystore: KeyStore = loadKeystoreFile(path); + const funder = keystore.fundingAccount as any; + expect(typeof funder.path).toBe('string'); + expect(existsSync(funder.path)).toBe(true); + }); }); describe('materialization helpers (invoked directly)', () => { @@ -1021,6 +1153,100 @@ describe('validator keys utilities', () => { }); }); + describe('setFundingAccount', () => { + const writeBaseKeystore = (path: string, extra: Record = {}) => { + const baseKeystore = { + schemaVersion: 1, + validators: [{ attester: '0x' + '0a'.repeat(32), feeRecipient: ('0x' + '06'.repeat(32)) as unknown as string }], + ...extra, + }; + writeFileSync(path, JSON.stringify(baseKeystore, null, 2), 'utf-8'); + }; + + it('sets a funding account on a keystore that has none', async () => { + const existing = join(tmp, 'set-funding.json'); + writeBaseKeystore(existing); + const fundingKey = '0x' + 'ab'.repeat(32); + + const logs: string[] = []; + await setFundingAccount(existing, fundingKey, {}, s => logs.push(s)); + + const updated: KeyStore = loadKeystoreFile(existing); + expect(updated.fundingAccount).toBe(fundingKey); + expect(logs.some(l => l.includes('Replacing existing funding account'))).toBe(false); + expect(logs.some(l => l.includes('Set funding account'))).toBe(true); + }); + + it('replaces an existing funding account and warns', async () => { + const existing = join(tmp, 'replace-funding.json'); + writeBaseKeystore(existing, { fundingAccount: '0x' + 'aa'.repeat(32) }); + const newFundingKey = '0x' + 'bb'.repeat(32); + + const logs: string[] = []; + await setFundingAccount(existing, newFundingKey, {}, s => logs.push(s)); + + const updated: KeyStore = loadKeystoreFile(existing); + expect(updated.fundingAccount).toBe(newFundingKey); + expect(logs.some(l => l.includes('Replacing existing funding account'))).toBe(true); + }); + + it('sets a remote-signer funding account from an address', async () => { + const existing = join(tmp, 'set-funding-address.json'); + writeBaseKeystore(existing); + const fundingAddress = '0x' + '02'.repeat(20); + const remoteSigner = 'http://localhost:9000'; + + await setFundingAccount(existing, fundingAddress, { remoteSigner }, s => s); + + const updated: KeyStore = loadKeystoreFile(existing); + const funder = updated.fundingAccount as any; + expect(funder.remoteSignerUrl).toBe(remoteSigner); + expect(funder.address.toString().toLowerCase()).toBe(fundingAddress); + }); + + it('inherits the keystore remote signer for an address when --remote-signer is omitted', async () => { + const existing = join(tmp, 'set-funding-inherit-signer.json'); + writeBaseKeystore(existing, { remoteSigner: 'http://localhost:9000' }); + const fundingAddress = '0x' + '02'.repeat(20); + + await setFundingAccount(existing, fundingAddress, {}, s => s); + + const updated: KeyStore = loadKeystoreFile(existing); + // Stored as a bare address (no inline remoteSignerUrl); resolved via the keystore-level signer at runtime. + const funder = updated.fundingAccount as any; + expect(funder.remoteSignerUrl).toBeUndefined(); + expect(funder.toString().toLowerCase()).toBe(fundingAddress); + }); + + it('rejects an address without a remote signer', async () => { + const existing = join(tmp, 'set-funding-no-signer.json'); + writeBaseKeystore(existing); + + await expect(setFundingAccount(existing, '0x' + '03'.repeat(20), {}, s => s)).rejects.toThrow( + /requires --remote-signer/, + ); + }); + + it('rejects a malformed funding account', async () => { + const existing = join(tmp, 'set-funding-malformed.json'); + writeBaseKeystore(existing); + + await expect(setFundingAccount(existing, '0xdead', {}, s => s)).rejects.toThrow(/Invalid funding account/); + }); + + it('encrypts a plaintext funding key when a password is provided', async () => { + const existing = join(tmp, 'set-funding-encrypted.json'); + writeBaseKeystore(existing); + + await setFundingAccount(existing, '0x' + 'cd'.repeat(32), { password: '', encryptedKeystoreDir: tmp }, s => s); + + const updated: KeyStore = loadKeystoreFile(existing); + const funder = updated.fundingAccount as any; + expect(typeof funder.path).toBe('string'); + expect(existsSync(funder.path)).toBe(true); + }); + }); + describe('generateBlsKeypair', () => { it('writes to file and logs a write message when out is provided', async () => { const out = join(tmp, 'bls.json'); diff --git a/yarn-project/node-keystore/src/keystore_manager.test.ts b/yarn-project/node-keystore/src/keystore_manager.test.ts index a0bcf268d5c..8452f1dbe0a 100644 --- a/yarn-project/node-keystore/src/keystore_manager.test.ts +++ b/yarn-project/node-keystore/src/keystore_manager.test.ts @@ -1605,5 +1605,43 @@ describe('KeystoreManager', () => { expect(signer).toBeUndefined(); }); + + it('resolves a bare-address fundingAccount via the keystore-level remote signer', async () => { + const fundingAddress = EthAddress.random(); + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: EthAddress.random(), + feeRecipient: await AztecAddress.random(), + }, + ], + remoteSigner: 'http://localhost:9000', + fundingAccount: fundingAddress, + }; + + const manager = new KeystoreManager(keystore); + const signer = manager.createFundingSigner(); + + expect(signer).toBeInstanceOf(RemoteSigner); + expect(signer!.address.equals(fundingAddress)).toBeTruthy(); + }); + + it('throws for a bare-address fundingAccount when no remote signer is configured', async () => { + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: EthAddress.random(), + feeRecipient: await AztecAddress.random(), + }, + ], + fundingAccount: EthAddress.random(), + }; + + const manager = new KeystoreManager(keystore); + + expect(() => manager.createFundingSigner()).toThrow(/No remote signer configuration found/); + }); }); });