From f00bd74268e7d481bbeed3ec1cc0f86b1b9c04ee Mon Sep 17 00:00:00 2001 From: aminsammara Date: Thu, 2 Jul 2026 20:01:12 +0000 Subject: [PATCH 1/4] feat(cli): support --funding-account in validator-keys new/add Wire the --funding-account option through the validator-keys new and add commands so operators can generate a keystore with a top-level funding account instead of hand-editing the JSON. The value may be a private key or, together with --remote-signer, an address. With --password the plaintext funder key is encrypted to a JSON V3 file like other accounts. The funding account is written at the keystore top level, matching the only field KeystoreManager.createFundingSigner consumes at runtime. --- .../cli/src/cmds/validator_keys/add.ts | 43 +++- .../cli/src/cmds/validator_keys/index.ts | 12 +- .../cli/src/cmds/validator_keys/new.ts | 23 ++- .../cli/src/cmds/validator_keys/shared.ts | 36 +++- .../cli/src/cmds/validator_keys/utils.ts | 41 +++- .../src/cmds/validator_keys/valkeys.test.ts | 188 +++++++++++++++++- 6 files changed, 325 insertions(+), 18 deletions(-) diff --git a/yarn-project/cli/src/cmds/validator_keys/add.ts b/yarn-project/cli/src/cmds/validator_keys/add.ts index d58f6de3501..676df7d4f8d 100644 --- a/yarn-project/cli/src/cmds/validator_keys/add.ts +++ b/yarn-project/cli/src/cmds/validator_keys/add.ts @@ -10,13 +10,20 @@ import { generateMnemonic } from 'viem/accounts'; import type { NewValidatorKeystoreOptions } from './new.js'; import { buildValidatorEntries, + encryptFundingAccountToFile, logValidatorSummaries, maybePrintJson, + resolveFundingAccount, writeBlsBn254ToFile, writeEthJsonV3ToFile, writeKeystoreFile, } from './shared.js'; -import { validateBlsPathOptions, validatePublisherOptions, validateRemoteSignerOptions } from './utils.js'; +import { + validateBlsPathOptions, + validateFundingAccountOptions, + validatePublisherOptions, + validateRemoteSignerOptions, +} from './utils.js'; export type AddValidatorKeysOptions = NewValidatorKeystoreOptions; @@ -27,6 +34,8 @@ export async function addValidatorKeys(existing: string, options: AddValidatorKe validatePublisherOptions(options); // validate remote signer options validateRemoteSignerOptions(options); + // validate funding account option + validateFundingAccountOptions(options); const { dataDir, @@ -43,6 +52,7 @@ export async function addValidatorKeys(existing: string, options: AddValidatorKe feeRecipient: feeRecipientOpt, coinbase: coinbaseOpt, remoteSigner: remoteSignerOpt, + fundingAccount, password, encryptedKeystoreDir, } = options; @@ -88,18 +98,31 @@ export async function addValidatorKeys(existing: string, options: AddValidatorKe keystore.validators.push(...validators); + const encryptedKeystoreOutDir = + encryptedKeystoreDir && encryptedKeystoreDir.length > 0 + ? encryptedKeystoreDir + : dataDir && dataDir.length > 0 + ? dataDir + : dirname(existing); + // If password provided, write ETH JSON V3 and BLS BN254 keystores and replace plaintext if (password !== undefined) { - let targetDir: string; - if (encryptedKeystoreDir && encryptedKeystoreDir.length > 0) { - targetDir = encryptedKeystoreDir; - } else if (dataDir && dataDir.length > 0) { - targetDir = dataDir; - } else { - targetDir = dirname(existing); + await writeEthJsonV3ToFile(keystore.validators, { outDir: encryptedKeystoreOutDir, password }); + await writeBlsBn254ToFile(keystore.validators, { outDir: encryptedKeystoreOutDir, password, blsPath }); + } + + if (fundingAccount) { + let resolvedFundingAccount = resolveFundingAccount(fundingAccount, remoteSigner); + if (password !== undefined) { + resolvedFundingAccount = await encryptFundingAccountToFile(resolvedFundingAccount, { + outDir: encryptedKeystoreOutDir, + password, + }); + } + if (keystore.fundingAccount) { + log('Replacing existing funding account in keystore'); } - await writeEthJsonV3ToFile(keystore.validators, { outDir: targetDir, password }); - await writeBlsBn254ToFile(keystore.validators, { outDir: targetDir, password, blsPath }); + keystore.fundingAccount = resolvedFundingAccount; } let outputPath = existing; diff --git a/yarn-project/cli/src/cmds/validator_keys/index.ts b/yarn-project/cli/src/cmds/validator_keys/index.ts index 8588f40c55a..80ce3d67ce3 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,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})`) 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/shared.ts b/yarn-project/cli/src/cmds/validator_keys/shared.ts index 981acc155e1..1ed49d002b9 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,19 @@ 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 paired with the remote signer URL. Callers must + * validate the value first (see `validateFundingAccountOptions`), which also guarantees a remote + * signer is present for the address form. + */ +export function resolveFundingAccount(fundingAccount: string, remoteSigner?: string): EthAccount { + if (fundingAccount.length === 66) { + return fundingAccount as EthPrivateKey; + } + return { address: EthAddress.fromString(fundingAccount), remoteSignerUrl: remoteSigner } as EthAccount; +} + export async function buildValidatorEntries(input: BuildValidatorsInput) { const { validatorCount, @@ -330,6 +343,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..f810b48005b 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,42 @@ 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, which requires `--remote-signer` since a local funder + * would need its private key to sign funding transactions. + */ +export function validateFundingAccountOptions(options: { fundingAccount?: string; remoteSigner?: string }) { + 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) { + throw new Error( + '--funding-account as an address requires --remote-signer (a local funder needs its private key to sign funding txs)', + ); + } + } 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..af6b5e4d0aa 100644 --- a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts +++ b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts @@ -24,7 +24,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 +402,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 +984,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: '', + 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)', () => { @@ -1019,6 +1150,61 @@ describe('validator keys utilities', () => { ), ).rejects.toThrow('Schema validation failed'); }); + + it('sets a funding account on a keystore that has none', async () => { + const existing = join(tmp, 'add-funding.json'); + const baseKeystore = { + schemaVersion: 1, + validators: [{ attester: '0x' + '0a'.repeat(32), feeRecipient: ('0x' + '06'.repeat(32)) as unknown as string }], + } as any; + writeFileSync(existing, JSON.stringify(baseKeystore, null, 2), 'utf-8'); + const fundingKey = '0x' + 'ab'.repeat(32); + + const logs: string[] = []; + await addValidatorKeys( + existing, + { + dataDir: tmp, + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: fundingKey, + feeRecipient: ('0x' + '06'.repeat(32)) as unknown as AztecAddress, + }, + 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); + }); + + it('overwrites an existing funding account and warns', async () => { + const existing = join(tmp, 'replace-funding.json'); + const baseKeystore = { + schemaVersion: 1, + validators: [{ attester: '0x' + '0a'.repeat(32), feeRecipient: ('0x' + '06'.repeat(32)) as unknown as string }], + fundingAccount: '0x' + 'aa'.repeat(32), + } as any; + writeFileSync(existing, JSON.stringify(baseKeystore, null, 2), 'utf-8'); + const newFundingKey = '0x' + 'bb'.repeat(32); + + const logs: string[] = []; + await addValidatorKeys( + existing, + { + dataDir: tmp, + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: newFundingKey, + feeRecipient: ('0x' + '06'.repeat(32)) as unknown as AztecAddress, + }, + 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); + }); }); describe('generateBlsKeypair', () => { From dd60c3ced7763e0bfcc79786121b608fc16a8971 Mon Sep 17 00:00:00 2001 From: aminsammara Date: Fri, 3 Jul 2026 10:49:11 +0000 Subject: [PATCH 2/4] refactor(cli): move funding account setting to set-funding-account subcommand Review feedback: --funding-account on add read as per-validator but mutated the keystore-level account. Keep it on new, drop it from add, and add a dedicated set-funding-account subcommand for existing keystores. --- .../cli/src/cmds/validator_keys/add.ts | 43 ++------- .../cli/src/cmds/validator_keys/index.ts | 24 ++++- .../validator_keys/set_funding_account.ts | 55 +++++++++++ .../src/cmds/validator_keys/valkeys.test.ts | 92 ++++++++++++------- 4 files changed, 144 insertions(+), 70 deletions(-) create mode 100644 yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts diff --git a/yarn-project/cli/src/cmds/validator_keys/add.ts b/yarn-project/cli/src/cmds/validator_keys/add.ts index 676df7d4f8d..d58f6de3501 100644 --- a/yarn-project/cli/src/cmds/validator_keys/add.ts +++ b/yarn-project/cli/src/cmds/validator_keys/add.ts @@ -10,20 +10,13 @@ import { generateMnemonic } from 'viem/accounts'; import type { NewValidatorKeystoreOptions } from './new.js'; import { buildValidatorEntries, - encryptFundingAccountToFile, logValidatorSummaries, maybePrintJson, - resolveFundingAccount, writeBlsBn254ToFile, writeEthJsonV3ToFile, writeKeystoreFile, } from './shared.js'; -import { - validateBlsPathOptions, - validateFundingAccountOptions, - validatePublisherOptions, - validateRemoteSignerOptions, -} from './utils.js'; +import { validateBlsPathOptions, validatePublisherOptions, validateRemoteSignerOptions } from './utils.js'; export type AddValidatorKeysOptions = NewValidatorKeystoreOptions; @@ -34,8 +27,6 @@ export async function addValidatorKeys(existing: string, options: AddValidatorKe validatePublisherOptions(options); // validate remote signer options validateRemoteSignerOptions(options); - // validate funding account option - validateFundingAccountOptions(options); const { dataDir, @@ -52,7 +43,6 @@ export async function addValidatorKeys(existing: string, options: AddValidatorKe feeRecipient: feeRecipientOpt, coinbase: coinbaseOpt, remoteSigner: remoteSignerOpt, - fundingAccount, password, encryptedKeystoreDir, } = options; @@ -98,31 +88,18 @@ export async function addValidatorKeys(existing: string, options: AddValidatorKe keystore.validators.push(...validators); - const encryptedKeystoreOutDir = - encryptedKeystoreDir && encryptedKeystoreDir.length > 0 - ? encryptedKeystoreDir - : dataDir && dataDir.length > 0 - ? dataDir - : dirname(existing); - // If password provided, write ETH JSON V3 and BLS BN254 keystores and replace plaintext if (password !== undefined) { - await writeEthJsonV3ToFile(keystore.validators, { outDir: encryptedKeystoreOutDir, password }); - await writeBlsBn254ToFile(keystore.validators, { outDir: encryptedKeystoreOutDir, password, blsPath }); - } - - if (fundingAccount) { - let resolvedFundingAccount = resolveFundingAccount(fundingAccount, remoteSigner); - if (password !== undefined) { - resolvedFundingAccount = await encryptFundingAccountToFile(resolvedFundingAccount, { - outDir: encryptedKeystoreOutDir, - password, - }); - } - if (keystore.fundingAccount) { - log('Replacing existing funding account in keystore'); + let targetDir: string; + if (encryptedKeystoreDir && encryptedKeystoreDir.length > 0) { + targetDir = encryptedKeystoreDir; + } else if (dataDir && dataDir.length > 0) { + targetDir = dataDir; + } else { + targetDir = dirname(existing); } - keystore.fundingAccount = resolvedFundingAccount; + await writeEthJsonV3ToFile(keystore.validators, { outDir: targetDir, password }); + await writeBlsBn254ToFile(keystore.validators, { outDir: targetDir, password, blsPath }); } let outputPath = existing; diff --git a/yarn-project/cli/src/cmds/validator_keys/index.ts b/yarn-project/cli/src/cmds/validator_keys/index.ts index 80ce3d67ce3..017bf83db2c 100644 --- a/yarn-project/cli/src/cmds/validator_keys/index.ts +++ b/yarn-project/cli/src/cmds/validator_keys/index.ts @@ -101,10 +101,6 @@ export function injectCommands(program: Command, log: LogFn) { 'Coinbase ETH address to use when proposing. Defaults to attester address.', parseEthereumAddress, ) - .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})`) @@ -121,6 +117,26 @@ 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 together with --remote-signer') + .option('--remote-signer ', 'Remote signer URL for the funding account (required with an address)') + .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/set_funding_account.ts b/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts new file mode 100644 index 00000000000..14f3ac4c3aa --- /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 validated = { fundingAccount, remoteSigner }; + validateFundingAccountOptions(validated); + + const keystore: KeyStore = loadKeystoreFile(existing); + + 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/valkeys.test.ts b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts index af6b5e4d0aa..bfb68d90076 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, @@ -1150,61 +1151,86 @@ describe('validator keys utilities', () => { ), ).rejects.toThrow('Schema validation failed'); }); + }); - it('sets a funding account on a keystore that has none', async () => { - const existing = join(tmp, 'add-funding.json'); + 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 }], - } as any; - writeFileSync(existing, JSON.stringify(baseKeystore, null, 2), 'utf-8'); + ...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 addValidatorKeys( - existing, - { - dataDir: tmp, - count: 1, - mnemonic: TEST_MNEMONIC, - fundingAccount: fundingKey, - feeRecipient: ('0x' + '06'.repeat(32)) as unknown as AztecAddress, - }, - s => logs.push(s), - ); + 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('overwrites an existing funding account and warns', async () => { + it('replaces an existing funding account and warns', async () => { const existing = join(tmp, 'replace-funding.json'); - const baseKeystore = { - schemaVersion: 1, - validators: [{ attester: '0x' + '0a'.repeat(32), feeRecipient: ('0x' + '06'.repeat(32)) as unknown as string }], - fundingAccount: '0x' + 'aa'.repeat(32), - } as any; - writeFileSync(existing, JSON.stringify(baseKeystore, null, 2), 'utf-8'); + writeBaseKeystore(existing, { fundingAccount: '0x' + 'aa'.repeat(32) }); const newFundingKey = '0x' + 'bb'.repeat(32); const logs: string[] = []; - await addValidatorKeys( - existing, - { - dataDir: tmp, - count: 1, - mnemonic: TEST_MNEMONIC, - fundingAccount: newFundingKey, - feeRecipient: ('0x' + '06'.repeat(32)) as unknown as AztecAddress, - }, - s => logs.push(s), - ); + 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('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', () => { From 6dcf923c811b17f7c5b8d8139bb1200388971840 Mon Sep 17 00:00:00 2001 From: aminsammara Date: Mon, 13 Jul 2026 15:21:34 +0000 Subject: [PATCH 3/4] feat(cli): inherit keystore remote signer for set-funding-account address form An address funding account no longer requires --remote-signer when the keystore already defines a top-level remote signer. In that case the funder is stored as a bare address, which the keystore manager resolves against the keystore-level signer at runtime, so operators don't declare the signer URL twice. --- .../cli/src/cmds/validator_keys/index.ts | 10 ++++- .../validator_keys/set_funding_account.ts | 6 +-- .../cli/src/cmds/validator_keys/shared.ts | 12 +++--- .../cli/src/cmds/validator_keys/utils.ts | 14 ++++--- .../src/cmds/validator_keys/valkeys.test.ts | 14 +++++++ .../src/keystore_manager.test.ts | 38 +++++++++++++++++++ 6 files changed, 79 insertions(+), 15 deletions(-) diff --git a/yarn-project/cli/src/cmds/validator_keys/index.ts b/yarn-project/cli/src/cmds/validator_keys/index.ts index 017bf83db2c..0c6088a6cbf 100644 --- a/yarn-project/cli/src/cmds/validator_keys/index.ts +++ b/yarn-project/cli/src/cmds/validator_keys/index.ts @@ -124,8 +124,14 @@ export function injectCommands(program: Command, log: LogFn) { '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 together with --remote-signer') - .option('--remote-signer ', 'Remote signer URL for the funding account (required with an address)') + .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', 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 index 14f3ac4c3aa..21258abb56b 100644 --- a/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts +++ b/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts @@ -28,11 +28,11 @@ export async function setFundingAccount( ) { const { remoteSigner, password, encryptedKeystoreDir, json } = options; - const validated = { fundingAccount, remoteSigner }; - validateFundingAccountOptions(validated); - 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); diff --git a/yarn-project/cli/src/cmds/validator_keys/shared.ts b/yarn-project/cli/src/cmds/validator_keys/shared.ts index 1ed49d002b9..64d16245450 100644 --- a/yarn-project/cli/src/cmds/validator_keys/shared.ts +++ b/yarn-project/cli/src/cmds/validator_keys/shared.ts @@ -126,16 +126,18 @@ export function deriveEthAttester( } /** - * 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 paired with the remote signer URL. Callers must - * validate the value first (see `validateFundingAccountOptions`), which also guarantees a remote - * signer is present for the address form. + * 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; } - return { address: EthAddress.fromString(fundingAccount), remoteSignerUrl: remoteSigner } as EthAccount; + const address = EthAddress.fromString(fundingAccount); + return remoteSigner ? ({ address, remoteSignerUrl: remoteSigner } as EthAccount) : address; } export async function buildValidatorEntries(input: BuildValidatorsInput) { diff --git a/yarn-project/cli/src/cmds/validator_keys/utils.ts b/yarn-project/cli/src/cmds/validator_keys/utils.ts index f810b48005b..1e83594f9b6 100644 --- a/yarn-project/cli/src/cmds/validator_keys/utils.ts +++ b/yarn-project/cli/src/cmds/validator_keys/utils.ts @@ -82,10 +82,14 @@ export function validatePublisherOptions(options: { publishers?: string[]; publi /** * 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, which requires `--remote-signer` since a local funder - * would need its private key to sign funding transactions. + * (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 }) { +export function validateFundingAccountOptions( + options: { fundingAccount?: string; remoteSigner?: string }, + hasKeystoreRemoteSigner = false, +) { if (!options.fundingAccount) { return; } @@ -107,9 +111,9 @@ export function validateFundingAccountOptions(options: { fundingAccount?: string } catch (error) { throw new Error(`Invalid funding account address: ${error instanceof Error ? error.message : String(error)}`); } - if (!options.remoteSigner) { + if (!options.remoteSigner && !hasKeystoreRemoteSigner) { throw new Error( - '--funding-account as an address requires --remote-signer (a local funder needs its private key to sign funding txs)', + '--funding-account as an address requires --remote-signer, or a keystore that already defines a remote signer', ); } } else { 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 bfb68d90076..df404f96166 100644 --- a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts +++ b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts @@ -1204,6 +1204,20 @@ describe('validator keys utilities', () => { 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); 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/); + }); }); }); From 7934b6dcb13574b426df82e635eb1e55f26570ff Mon Sep 17 00:00:00 2001 From: aminsammara Date: Mon, 13 Jul 2026 18:41:29 +0000 Subject: [PATCH 4/4] test(cli): use non-empty password in funding encryption test The base now rejects empty keystore passwords on validator-keys new, so the funding-account encryption test must supply a real password. --- yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 df404f96166..e647115f759 100644 --- a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts +++ b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts @@ -1067,7 +1067,7 @@ describe('validator keys utilities', () => { count: 1, mnemonic: TEST_MNEMONIC, fundingAccount: '0x' + 'cd'.repeat(32), - password: '', + password: 'funding-test-pw', encryptedKeystoreDir: tmp, feeRecipient: ('0x' + '15'.repeat(32)) as unknown as AztecAddress, },