Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions yarn-project/cli/src/cmds/validator_keys/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <privateKey|address>', 'ETH private key (or address for remote signer setup) to fund publishers')
.option(
'--funding-account <privateKey|address>',
'ETH funding account used to top up publisher EOAs. Provide a private key, or an address together with --remote-signer.',
)
.option('--remote-signer <url>', 'Default remote signer URL for accounts in this file')
.option('--ikm <hex>', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32))
.option('--bls-path <path>', `EIP-2334 path (default ${defaultBlsPath})`)
Expand Down Expand Up @@ -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 <privateKey|address>', 'ETH private key (or address for remote signer setup) to fund publishers')
.option('--remote-signer <url>', 'Default remote signer URL for accounts in this file')
.option('--ikm <hex>', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32))
.option('--bls-path <path>', `EIP-2334 path (default ${defaultBlsPath})`)
Expand All @@ -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('<existing>', 'Path to existing keystore JSON')
.argument(
'<privateKey|address>',
'Funding account: a private key, or an address (needs --remote-signer unless the keystore already defines one)',
)
.option(
'--remote-signer <url>',
'Remote signer URL for the funding account (required with an address unless the keystore already defines one)',
)
.option(
'--password <str>',
'Password for writing the funding key as an encrypted ETH JSON V3 file. Empty string allowed',
)
.option('--encrypted-keystore-dir <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')
Expand Down
23 changes: 22 additions & 1 deletion yarn-project/cli/src/cmds/validator_keys/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +25,7 @@ import {
import { processAttesterAccounts } from './staker.js';
import {
validateBlsPathOptions,
validateFundingAccountOptions,
validatePublisherOptions,
validateRemoteSignerOptions,
validateStakerOutputOptions,
Expand Down Expand Up @@ -51,6 +54,7 @@ export type NewValidatorKeystoreOptions = {
json?: boolean;
feeRecipient: AztecAddress;
coinbase?: EthAddress;
fundingAccount?: string;
remoteSigner?: string;
stakerOutput?: boolean;
gseAddress?: EthAddress;
Expand Down Expand Up @@ -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,
Expand All @@ -156,6 +162,7 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions,
publishers,
json,
coinbase,
fundingAccount,
accountIndex = 0,
addressIndex = 0,
feeRecipient,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
55 changes: 55 additions & 0 deletions yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>);
}
38 changes: 37 additions & 1 deletion yarn-project/cli/src/cmds/validator_keys/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<EthAccount> {
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[],
Expand Down
45 changes: 44 additions & 1 deletion yarn-project/cli/src/cmds/validator_keys/utils.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
Loading
Loading