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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- add explicit `--password-file` support to every command that unlocks a wallet, and `--mnemonic-file` to `create-wallet --import`. These POSIX inputs are mutually exclusive with their literal counterparts and require owner-only, regular, non-symlink files; they fail closed on Windows where ACLs are not validated.

## [0.0.5] — 2026-08-29

### Added
Expand Down Expand Up @@ -38,4 +44,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed

- Unshielding to a custom / ephemeral recipient (`--to` an address that is not a stored HD account) no longer fails or mis-routes funds.

15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,15 @@ Global behavior:
| **Default privacy protocol** | Env `DEFAULT_PRIVACY_PROTOCOL` (`tornado` \| `railgun` \| `privacy-pools`). When set, `shield` / `unshield` may omit `--protocol`, and `balances` includes that protocol by default. Examples below still pass `--protocol` / `--include` explicitly. |
| **Data directory** | `--dataDir <path>` (default `~/.kohaku-cli`). |
| **Networks** | Wallets created with `--testnet` expect Sepolia (`11155111`); otherwise mainnet (`1`). RPC chain ID must match the wallet. |
| **`--non-interactive`** | Available on every command below. Skips prompts and spinners; prints **JSON** where applicable. Requires flags documented per command (`--password`, `--wallet`, amounts, `--from`, `--to` / `--next`, etc.). Use for CI, agents, and piping output. |
| **`--password`** | Wallet unlock password. In non-interactive mode, required where the wallet is encrypted. Value can be a literal string or a path to a file containing the password. |
| **`--non-interactive`** | Available on every command below. Skips prompts and spinners; prints **JSON** where applicable. Requires flags documented per command (`--password-file` or `--password`, `--wallet`, amounts, `--from`, `--to` / `--next`, etc.). Use for CI, agents, and piping output. |
| **`--password-file <path>`** | Preferred for automation on POSIX. Reads the wallet password from a regular, non-symlink file, so the password is not exposed in process arguments. The file must be owned by the current user with mode `0400` or `0600`. Secure file inputs fail closed on Windows because ACLs are not validated. Mutually exclusive with `--password`. |
| **`--password <password>`** | Legacy wallet unlock input. A literal string or existing file path is accepted for compatibility; prefer the unambiguous `--password-file` form in scripts. |
| **`--without-tor`** | Disable Tor for non-RPC HTTP (default: Tor on for private-protocol and Pimlico-backed commands, including `transfer` / `transact-raw` / names). Or set `KOHAKU_WITHOUT_TOR=1`. Ethereum RPC stays clearnet. Review contacts with `view-network-traffic`. |
| **Proving artifacts** | Railgun/Tornado keys live under `<dataDir>/proving-artifacts`. Pre-warm with `fetch-artifacts`. Remote base: `KOHAKU_ARTIFACTS_BASE_URL` (default `https://artifacts.0000000000.org`). Large Tor GETs: `KOHAKU_TOR_CDN_TIMEOUT_MS` (default `45000`). Debug: `KOHAKU_TOR_DEBUG=1`. |
| **Public-sync cache** | Shared **Railgun Subsquid** and **Tornado saga** HTTP pages live under `<dataDir>/public-sync-cache` and speed up those syncs (`balances`, `shield`, `unshield`). Prefetch with `fetch-sync-cache`. Snapshot base: `KOHAKU_SYNC_CACHE_BASE_URL` (default `https://artifacts.0000000000.org/sync-cache/v1`). Snapshot is historical; live HTTP still fills anything newer. Never evicts — at `KOHAKU_PUBLIC_SYNC_CACHE_MAX_BYTES` (default 1 GiB) new pages stop being stored instead. Privacy Pools is **not** covered (its cold sync is bundled state JSON plus `eth_getLogs`, which is never HTTP-cached). Wipe with `kohaku clear-tor-cache --public-sync`. |

`--password-file` removes one trailing line ending and otherwise preserves password whitespace. When unlocking an existing wallet, it also retries the fully trimmed value for compatibility with the legacy `--password <existing-file>` behavior. New wallets created with `--password-file` use the preserved value exactly.

---

### `create-wallet <name>`
Expand All @@ -178,7 +181,9 @@ Create a BIP-39 seed wallet encrypted on disk. The `<name>` argument is a single
| `--rpc-url <url>` | Required with `--import` (or `RPC_URL`) to scan used addresses. Optional for new wallets when writing `.stealth-start-block`. |
| `--stealth-start-block [block]` | With `--import`: write `.stealth-start-block`. Omit the flag to record the current tip (same as a new wallet). Bare flag: Kohaku floor (mainnet `25700000`, Sepolia `11455454`). With a number: that block, rounded up to the ERC-5564 announcer deploy if lower. |
| `--mnemonic <phrase>` | Mnemonic (required with `--non-interactive --import`). |
| `--password <password>` | Encryption password (required with `--non-interactive`). |
| `--mnemonic-file <path>` | Preferred POSIX import input for automation. Reads the mnemonic from an owner-only regular file without following symlinks; fails closed on Windows. Mutually exclusive with `--mnemonic`. |
| `--password <password>` | Encryption password required with `--non-interactive`. As before, interactive creation prompts instead of consuming this legacy flag. |
| `--password-file <path>` | Preferred POSIX automation input; see global behavior above. Mutually exclusive with `--password`. |
| `--non-interactive` | No prompts; no mnemonic box on create. |
| `--dataDir <path>` | Data root. |

Expand All @@ -192,6 +197,8 @@ kohaku create-wallet myWallet24 --testnet --long-seed
kohaku create-wallet restored --testnet --import --rpc-url "$RPC_URL"
kohaku create-wallet restored --testnet --import --rpc-url "$RPC_URL" --stealth-start-block
kohaku create-wallet restored --testnet --import --rpc-url "$RPC_URL" --stealth-start-block 10000000
chmod 600 ./wallet-password ./wallet-mnemonic
kohaku create-wallet restored-safe --testnet --import --rpc-url "$RPC_URL" --password-file ./wallet-password --mnemonic-file ./wallet-mnemonic --non-interactive
```

---
Expand Down Expand Up @@ -853,4 +860,4 @@ Files include `public-accounts.json`, stealth storage, `rg-storage.json`, `ppv1-
- **Privacy Pools note size:** Each unshield uses one note; large shields may require multiple unshields if balances are split across notes.
- **Tornado notes:** Use `export-tornado-note` / `import-tornado-note` to move legacy note secrets between wallets for testing or recovery.
- **Private key / seed exports:** `export-private-key`, `reveal-seed-phrase`, and `export-tornado-note` print raw secrets to stdout. Avoid terminal logs, shell history, and shared environments.
- **Agents:** Pass `--non-interactive --password … --wallet …` and parse JSON stdout; set `RPC_URL` in the environment to avoid repeating `--rpc-url`.
- **Agents:** Pass `--non-interactive --password-file … --wallet …` and parse JSON stdout. Set `RPC_URL` in the environment to avoid repeating `--rpc-url`. Secret files must be regular files, cannot be symlinks, and on POSIX must be owned by the current user with mode `0400` or `0600` (`chmod 600`).
5 changes: 4 additions & 1 deletion src/commands/balances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
type StealthWalletProfile,
} from "../lib/stealth/storage.js";
import { resolveRegisterSigner } from "../lib/names/ownership.js";
import { cliOptions } from "../utils/cli-command-options";
import { cliOptions, passwordFileOption } from "../utils/cli-command-options";
import { quietNonInteractive, runQuietSpinner, manageSpinner } from "../utils/cli-quiet";
import { cliError, cliErrorFromCaught } from "../utils/cli-errors";
import {
Expand All @@ -47,6 +47,7 @@ import { readSeedKeystore } from "../utils/mnemonic";
type BalancesOpts = {
wallet?: string;
password?: string;
passwordFile?: string;
nonInteractive?: boolean;
verbose?: boolean;
include?: string;
Expand Down Expand Up @@ -493,6 +494,7 @@ export function registerBalancesCommand(program: Command): void {
)
.option("--wallet <name>", cliOptions.walletBalancesOptional)
.option("--password <password>", cliOptions.password)
.addOption(passwordFileOption())
.option("--non-interactive", cliOptions.nonInteractiveBalances)
.option(
"--verbose",
Expand Down Expand Up @@ -562,6 +564,7 @@ export function registerBalancesCommand(program: Command): void {

const password = await resolveWalletPassword({
flagPassword: opts.password,
flagPasswordFile: opts.passwordFile,
nonInteractive: opts.nonInteractive,
validate: (candidate) => {
readSeedKeystore(candidate, walletDir);
Expand Down
68 changes: 58 additions & 10 deletions src/commands/createWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { password } from "@inquirer/prompts";
import chalk from "chalk";
import type { Command } from "commander";

import { cliOptions } from "../utils/cli-command-options";
import {
cliOptions,
mnemonicFileOption,
passwordFileOption,
} from "../utils/cli-command-options";
import { cliError, cliErrorFromCaught } from "../utils/cli-errors";
import {
createWalletOnDisk,
Expand All @@ -21,19 +25,30 @@ import {
resolveWalletDir,
} from "../utils/wallets-util";
import { normalizeValidatedMnemonic } from "../utils/mnemonic";
import { readSecretFile } from "../utils/secret-file";

type CreateWalletOpts = {
import?: boolean;
nonInteractive?: boolean;
password?: string;
passwordFile?: string;
mnemonic?: string;
mnemonicFile?: string;
rpcUrl?: string;
testnet?: boolean;
longSeed?: boolean;
stealthStartBlock?: string | true;
dataDir?: string;
};

export function shouldResolveCreatePasswordFromFlags(opts: {
nonInteractive?: boolean;
password?: string;
passwordFile?: string;
}): boolean {
return !!opts.nonInteractive || opts.passwordFile !== undefined;
}

function printMnemonicBox(mnemonic: string): void {
const line = mnemonic.trim();
const inner = Math.max(line.length + 4, 44);
Expand Down Expand Up @@ -89,13 +104,15 @@ export function registerCreateWalletCommand(program: Command): void {
.option("--import", "Paste an existing mnemonic instead of generating one")
.option(
"--non-interactive",
"Agent mode: no interactive prompts (requires --password and other flags as documented)"
"Agent mode: no prompts (requires --password/--password-file and other documented flags)"
)
.option(
"--password <password>",
"Password to encrypt this wallet (required with --non-interactive; else prompted)"
"Non-interactive encryption password (legacy literal-or-file input)"
)
.addOption(passwordFileOption())
.option("--mnemonic <phrase>", "Mnemonic phrase (required with --non-interactive --import)")
.addOption(mnemonicFileOption())
.option("--rpc-url <url>", "RPC URL (or set RPC_URL). Optional for new wallets: a public RPC is used to record the current block if unset. Required with --import")
.option("--testnet", "Use testnet chain ID (11155111) instead of mainnet (1)")
.option(
Expand All @@ -116,6 +133,10 @@ export function registerCreateWalletCommand(program: Command): void {
cliError("--long-seed only applies when generating a new mnemonic (omit --import).");
return;
}
if (!opts.import && opts.mnemonicFile !== undefined) {
cliError("--mnemonic-file only applies with --import.");
return;
}

const dataDir = opts.dataDir ?? DEFAULT_DATA_DIR;
let walletDir: string;
Expand All @@ -134,14 +155,30 @@ export function registerCreateWalletCommand(program: Command): void {
let mnemonicPhrase: string;
let importRpcUrl: string | undefined;
if (opts.import) {
const pasted = opts.nonInteractive
? opts.mnemonic
: await password({
let pasted: string | undefined;
if (opts.mnemonicFile !== undefined) {
try {
pasted = readSecretFile(opts.mnemonicFile, {
label: "Mnemonic",
allowNewlines: true,
maxBytes: 4096,
}).replace(/\s+/g, " ");
} catch (e) {
cliErrorFromCaught(e);
return;
}
} else {
pasted = opts.nonInteractive
? opts.mnemonic
: await password({
message: "Enter your 12 or 24-word mnemonic:",
mask: "*",
});
}
if (opts.nonInteractive && !pasted?.trim()) {
cliError("--mnemonic is required when using --non-interactive --import.");
cliError(
"--mnemonic or --mnemonic-file is required when using --non-interactive --import."
);
return;
}
importRpcUrl = resolveRpcUrl(opts.rpcUrl);
Expand All @@ -160,10 +197,21 @@ export function registerCreateWalletCommand(program: Command): void {
}

let encryptPassword: string;
if (opts.nonInteractive) {
const resolved = resolvePasswordInputPreferFile(opts.password);
if (shouldResolveCreatePasswordFromFlags(opts)) {
let resolved: string | null;
try {
resolved = resolvePasswordInputPreferFile(
opts.nonInteractive ? opts.password : undefined,
opts.passwordFile
);
} catch (e) {
cliErrorFromCaught(e);
return;
}
if (!resolved) {
cliError("--password is required when using --non-interactive.");
cliError(
"--password or --password-file is required when using --non-interactive."
);
return;
}
encryptPassword = resolved;
Expand Down
5 changes: 4 additions & 1 deletion src/commands/export-tornado-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { SecretManager, TornadoCashConfigs, type TCNote } from "@kohaku-eth/torn

import { withProtocolRuntime } from "../lib/protocol-runtime.js";
import { addressishToHex } from "../lib/private-notes.js";
import { cliOptions } from "../utils/cli-command-options";
import { cliOptions, passwordFileOption } from "../utils/cli-command-options";
import { cliError, cliErrorFromCaught } from "../utils/cli-errors";
import {
logCliJson,
Expand Down Expand Up @@ -39,6 +39,7 @@ import {
type ExportTornadoNoteOpts = {
wallet?: string;
password?: string;
passwordFile?: string;
rpcUrl?: string;
token?: string;
amountWei?: string;
Expand Down Expand Up @@ -153,6 +154,7 @@ export function registerExportTornadoNoteCommand(program: Command): void {
)
.option("--wallet <name>", cliOptions.walletPickList)
.option("--password <password>", cliOptions.password)
.addOption(passwordFileOption())
.option("--rpc-url <url>", cliOptions.rpcUrl)
.option("--token <address|symbol|eth>", "Token address or symbol (default: eth)")
.option("--amount-wei <amount>", "Exact pool denomination in wei/base units")
Expand Down Expand Up @@ -196,6 +198,7 @@ export function registerExportTornadoNoteCommand(program: Command): void {

const password = await resolveWalletPassword({
flagPassword: opts.password,
flagPasswordFile: opts.passwordFile,
nonInteractive: opts.nonInteractive,
validate: (candidate) => {
readSeedKeystore(candidate, walletDir);
Expand Down
5 changes: 4 additions & 1 deletion src/commands/exportPrivateKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getAddress, isAddress } from "viem";
import { readSeedKeystore } from "../utils/mnemonic";
import { findPublicAccountByAddress, makePublicAccountsStorage } from "../utils/public-accounts";
import { DEFAULT_DATA_DIR, resolveRpcUrl } from "../utils/rpc";
import { cliOptions } from "../utils/cli-command-options";
import { cliOptions, passwordFileOption } from "../utils/cli-command-options";
import { cliError, cliErrorFromCaught } from "../utils/cli-errors";
import { resolveAddressOrName } from "../utils/resolve-name.js";
import { addressFromPrivateKey } from "../utils/viem-tx.js";
Expand All @@ -19,6 +19,7 @@ import {
type ExportPrivateKeyOpts = {
wallet?: string;
password?: string;
passwordFile?: string;
address?: string;
index?: string;
rpcUrl?: string;
Expand All @@ -39,6 +40,7 @@ export function registerExportPrivateKeyCommand(program: Command): void {
.description("Export the private key for a wallet public account")
.option("--wallet <name>", cliOptions.walletPickList)
.option("--password <password>", cliOptions.password)
.addOption(passwordFileOption())
.option("--address <address>", "Public account address or ENS/GNS/WNS name to export")
.option("--index <index>", "Public account index to export")
.option("--rpc-url <url>", `${cliOptions.rpcUrl} (required when --address is a name)`)
Expand Down Expand Up @@ -72,6 +74,7 @@ export function registerExportPrivateKeyCommand(program: Command): void {

const password = await resolveWalletPassword({
flagPassword: opts.password,
flagPasswordFile: opts.passwordFile,
nonInteractive: opts.nonInteractive,
validate: (candidate) => {
readSeedKeystore(candidate, walletDir);
Expand Down
5 changes: 4 additions & 1 deletion src/commands/import-tornado-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Command } from "commander";
import type { ImportNoteResult } from "@kohaku-eth/tornado-cash";

import { withProtocolRuntime } from "../lib/protocol-runtime.js";
import { cliOptions } from "../utils/cli-command-options";
import { cliOptions, passwordFileOption } from "../utils/cli-command-options";
import { cliError, cliErrorFromCaught } from "../utils/cli-errors";
import {
logCliJson,
Expand All @@ -30,6 +30,7 @@ import {
type ImportTornadoNoteOpts = {
wallet?: string;
password?: string;
passwordFile?: string;
rpcUrl?: string;
nonInteractive?: boolean;
withoutTor?: boolean;
Expand Down Expand Up @@ -82,6 +83,7 @@ export function registerImportTornadoNoteCommand(program: Command): void {
)
.option("--wallet <name>", cliOptions.walletPickList)
.option("--password <password>", cliOptions.password)
.addOption(passwordFileOption())
.option("--rpc-url <url>", cliOptions.rpcUrl)
.option("--non-interactive", cliOptions.nonInteractiveCompact)
.option("--without-tor", cliOptions.withoutTor)
Expand Down Expand Up @@ -115,6 +117,7 @@ export function registerImportTornadoNoteCommand(program: Command): void {

const password = await resolveWalletPassword({
flagPassword: opts.password,
flagPasswordFile: opts.passwordFile,
nonInteractive: opts.nonInteractive,
validate: (candidate) => {
readSeedKeystore(candidate, walletDir);
Expand Down
5 changes: 4 additions & 1 deletion src/commands/nextFreshAddress.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Command } from "commander";

import { cliOptions } from "../utils/cli-command-options";
import { cliOptions, passwordFileOption } from "../utils/cli-command-options";
import { cliErrorFromCaught } from "../utils/cli-errors";
import { makePublicAccountsStorage } from "../utils/public-accounts";
import { DEFAULT_DATA_DIR } from "../utils/rpc";
Expand All @@ -14,6 +14,7 @@ import { readSeedKeystore } from "../utils/mnemonic";
type NextFreshAddressOpts = {
wallet?: string;
password?: string;
passwordFile?: string;
peek?: boolean;
nonInteractive?: boolean;
dataDir?: string;
Expand All @@ -25,6 +26,7 @@ export function registerNextFreshAddressCommand(program: Command): void {
.description("Generate and persist the next public account address")
.option("--wallet <name>", cliOptions.walletPickList)
.option("--password <password>", cliOptions.password)
.addOption(passwordFileOption())
.option(
"--peek",
"Print the next fresh address without persisting it (useful for crafting payloads before --next)"
Expand All @@ -50,6 +52,7 @@ export function registerNextFreshAddressCommand(program: Command): void {

const password = await resolveWalletPassword({
flagPassword: opts.password,
flagPasswordFile: opts.passwordFile,
nonInteractive: opts.nonInteractive,
validate: (candidate) => {
readSeedKeystore(candidate, walletDir);
Expand Down
Loading