@did-btcr2/cli 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +106 -17
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +1160 -131
  4. package/dist/esm/src/cli.js +29 -5
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/config.js +130 -18
  7. package/dist/esm/src/commands/config.js.map +1 -1
  8. package/dist/esm/src/commands/create.js +13 -1
  9. package/dist/esm/src/commands/create.js.map +1 -1
  10. package/dist/esm/src/commands/deactivate.js +16 -2
  11. package/dist/esm/src/commands/deactivate.js.map +1 -1
  12. package/dist/esm/src/commands/index.js +2 -0
  13. package/dist/esm/src/commands/index.js.map +1 -1
  14. package/dist/esm/src/commands/init.js +63 -0
  15. package/dist/esm/src/commands/init.js.map +1 -0
  16. package/dist/esm/src/commands/keystore.js +81 -0
  17. package/dist/esm/src/commands/keystore.js.map +1 -0
  18. package/dist/esm/src/commands/profile.js +6 -4
  19. package/dist/esm/src/commands/profile.js.map +1 -1
  20. package/dist/esm/src/commands/update.js +16 -2
  21. package/dist/esm/src/commands/update.js.map +1 -1
  22. package/dist/esm/src/config-schema.js +149 -0
  23. package/dist/esm/src/config-schema.js.map +1 -0
  24. package/dist/esm/src/config.js +579 -55
  25. package/dist/esm/src/config.js.map +1 -1
  26. package/dist/esm/src/keystore/file-key-store.js +340 -32
  27. package/dist/esm/src/keystore/file-key-store.js.map +1 -1
  28. package/dist/esm/src/keystore/passphrase.js +40 -10
  29. package/dist/esm/src/keystore/passphrase.js.map +1 -1
  30. package/dist/esm/src/keystore/paths.js +6 -17
  31. package/dist/esm/src/keystore/paths.js.map +1 -1
  32. package/dist/esm/src/output.js +49 -0
  33. package/dist/esm/src/output.js.map +1 -1
  34. package/dist/esm/src/paths.js +59 -0
  35. package/dist/esm/src/paths.js.map +1 -0
  36. package/dist/esm/src/types.js +11 -0
  37. package/dist/esm/src/types.js.map +1 -1
  38. package/dist/types/src/cli.d.ts.map +1 -1
  39. package/dist/types/src/commands/config.d.ts.map +1 -1
  40. package/dist/types/src/commands/create.d.ts.map +1 -1
  41. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  42. package/dist/types/src/commands/index.d.ts +2 -0
  43. package/dist/types/src/commands/index.d.ts.map +1 -1
  44. package/dist/types/src/commands/init.d.ts +13 -0
  45. package/dist/types/src/commands/init.d.ts.map +1 -0
  46. package/dist/types/src/commands/keystore.d.ts +10 -0
  47. package/dist/types/src/commands/keystore.d.ts.map +1 -0
  48. package/dist/types/src/commands/profile.d.ts.map +1 -1
  49. package/dist/types/src/commands/update.d.ts.map +1 -1
  50. package/dist/types/src/config-schema.d.ts +24 -0
  51. package/dist/types/src/config-schema.d.ts.map +1 -0
  52. package/dist/types/src/config.d.ts +252 -14
  53. package/dist/types/src/config.d.ts.map +1 -1
  54. package/dist/types/src/keystore/file-key-store.d.ts +86 -10
  55. package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
  56. package/dist/types/src/keystore/passphrase.d.ts +16 -1
  57. package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
  58. package/dist/types/src/keystore/paths.d.ts +6 -10
  59. package/dist/types/src/keystore/paths.d.ts.map +1 -1
  60. package/dist/types/src/output.d.ts +22 -0
  61. package/dist/types/src/output.d.ts.map +1 -1
  62. package/dist/types/src/paths.d.ts +54 -0
  63. package/dist/types/src/paths.d.ts.map +1 -0
  64. package/dist/types/src/types.d.ts +70 -0
  65. package/dist/types/src/types.d.ts.map +1 -1
  66. package/package.json +5 -5
  67. package/src/cli.ts +32 -4
  68. package/src/commands/config.ts +143 -18
  69. package/src/commands/create.ts +16 -1
  70. package/src/commands/deactivate.ts +24 -2
  71. package/src/commands/index.ts +2 -0
  72. package/src/commands/init.ts +74 -0
  73. package/src/commands/keystore.ts +98 -0
  74. package/src/commands/profile.ts +6 -4
  75. package/src/commands/update.ts +24 -2
  76. package/src/config-schema.ts +178 -0
  77. package/src/config.ts +752 -58
  78. package/src/keystore/file-key-store.ts +455 -43
  79. package/src/keystore/passphrase.ts +48 -8
  80. package/src/keystore/paths.ts +6 -18
  81. package/src/output.ts +53 -0
  82. package/src/paths.ts +79 -0
  83. package/src/types.ts +34 -0
@@ -1,19 +1,27 @@
1
- import { createApi, Identifier } from '@did-btcr2/api';
1
+ import { createApi, DEFAULT_BITCOIN_NETWORK_CONFIG, DEFAULT_CAS_GATEWAY, Identifier } from '@did-btcr2/api';
2
+ import { StaticFeeEstimator } from '@did-btcr2/method';
2
3
  import { readFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname } from 'node:path';
5
5
  import { CLIError } from './error.js';
6
6
  import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
7
7
  import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
8
+ import { keystoreProtection } from './keystore/file-key-store.js';
8
9
  import { defaultKeystorePath } from './keystore/paths.js';
9
10
  import { acquirePassphrase } from './keystore/passphrase.js';
10
- import { SUPPORTED_NETWORKS } from './types.js';
11
+ import { defaultConfigPath } from './paths.js';
12
+ import { blankToUndef, SUPPORTED_NETWORKS } from './types.js';
13
+ export { defaultConfigPath };
11
14
  /** Current config-file schema version, stamped on every write. */
12
15
  export const CONFIG_SCHEMA_VERSION = 1;
13
16
  /**
14
17
  * Read-modify-write a config file, preserving unknown keys. Reads the raw JSON
15
18
  * (so keys outside {@link ConfigFile} survive a rewrite), applies `mutate`,
16
19
  * stamps the schema version, and writes atomically (file 0600, dir 0700).
20
+ *
21
+ * A file that exists but cannot be parsed makes {@link readConfigFile} throw, so
22
+ * a write never starts from `{}` over a malformed-but-recoverable file and can
23
+ * never clobber the other profiles and defaults it still holds. A genuinely
24
+ * absent file (ENOENT) still starts from `{}`.
17
25
  */
18
26
  export function writeConfigFile(path, mutate) {
19
27
  const raw = readConfigFile(path) ?? {};
@@ -22,13 +30,37 @@ export function writeConfigFile(path, mutate) {
22
30
  ensureDir(dirname(path), 0o700);
23
31
  writeFileAtomic(path, `${JSON.stringify(raw, null, 2)}\n`, 0o600);
24
32
  }
33
+ /**
34
+ * Writes a default config scaffold to `path`: schema version, a `text` output
35
+ * default, and one empty profile per supported network. Shared by `config init`
36
+ * and `btcr2 init` so the seeded config is identical. Writes atomically (file
37
+ * 0600, dir 0700); the caller decides whether to overwrite an existing file.
38
+ */
39
+ export function writeDefaultConfigFile(path) {
40
+ const scaffold = {
41
+ schemaVersion: CONFIG_SCHEMA_VERSION,
42
+ defaults: { output: 'text' },
43
+ profiles: Object.fromEntries(SUPPORTED_NETWORKS.map(n => [n, {}])),
44
+ };
45
+ ensureDir(dirname(path), 0o700);
46
+ writeFileAtomic(path, `${JSON.stringify(scaffold, null, 2)}\n`, 0o600);
47
+ }
25
48
  /** Reads the value at a dotted path (e.g. `profiles.regtest.btc.rest`). */
26
49
  export function getConfigPath(config, path) {
27
50
  return path.split('.').reduce((node, key) => node?.[key], config);
28
51
  }
52
+ /** Dotted-path segments that would let a write reach the prototype chain. */
53
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
54
+ /** Rejects a path segment that would let a `config set`/`unset` reach the prototype chain. */
55
+ function assertSafeKey(key, path) {
56
+ if (UNSAFE_KEYS.has(key)) {
57
+ throw new CLIError(`Illegal config path segment "${key}" in "${path}".`, 'INVALID_ARGUMENT_ERROR', { path, key });
58
+ }
59
+ }
29
60
  /** Sets the value at a dotted path, creating intermediate objects. */
30
61
  export function setConfigPath(config, path, value) {
31
62
  const keys = path.split('.');
63
+ keys.forEach(key => assertSafeKey(key, path));
32
64
  const last = keys.pop();
33
65
  if (!last)
34
66
  throw new CLIError('Config path must be non-empty.', 'INVALID_ARGUMENT_ERROR');
@@ -43,6 +75,7 @@ export function setConfigPath(config, path, value) {
43
75
  /** Deletes the value at a dotted path. No-op if the path does not exist. */
44
76
  export function unsetConfigPath(config, path) {
45
77
  const keys = path.split('.');
78
+ keys.forEach(key => assertSafeKey(key, path));
46
79
  const last = keys.pop();
47
80
  if (!last)
48
81
  return;
@@ -65,6 +98,9 @@ export function unsetConfigPath(config, path) {
65
98
  * | `BTCR2_BTC_RPC_PASS` | `--btc-rpc-pass` |
66
99
  * | `BTCR2_CAS_GATEWAY` | `--cas-gateway` |
67
100
  * | `BTCR2_CAS_RPC_URL` | `--cas-rpc-url` |
101
+ * | `BTCR2_BTC_TIMEOUT` | `--btc-timeout` |
102
+ * | `BTCR2_CAS_TIMEOUT` | `--cas-timeout` |
103
+ * | `BTCR2_FEE_RATE` | `--fee-rate` |
68
104
  */
69
105
  export const ENV_VARS = {
70
106
  BTC_REST: 'BTCR2_BTC_REST',
@@ -73,6 +109,9 @@ export const ENV_VARS = {
73
109
  BTC_RPC_PASS: 'BTCR2_BTC_RPC_PASS',
74
110
  CAS_GATEWAY: 'BTCR2_CAS_GATEWAY',
75
111
  CAS_RPC_URL: 'BTCR2_CAS_RPC_URL',
112
+ BTC_TIMEOUT: 'BTCR2_BTC_TIMEOUT',
113
+ CAS_TIMEOUT: 'BTCR2_CAS_TIMEOUT',
114
+ FEE_RATE: 'BTCR2_FEE_RATE',
76
115
  };
77
116
  /**
78
117
  * Reads {@link ConnectionOverrides} from environment variables.
@@ -90,30 +129,58 @@ export function readEnvOverrides() {
90
129
  };
91
130
  }
92
131
  /**
93
- * Default config file path following the XDG Base Directory Specification.
94
- *
95
- * Resolution order:
96
- * 1. `$XDG_CONFIG_HOME/btcr2/config.json`
97
- * 2. `%APPDATA%/btcr2/config.json` (Windows)
98
- * 3. `~/.config/btcr2/config.json` (fallback)
132
+ * Reads and JSON-parses a config file without applying the schema-version
133
+ * ceiling check. Returns `undefined` only for a genuinely absent file (ENOENT).
134
+ * Any other read failure, and any JSON parse failure, throws a {@link CLIError}
135
+ * that names the file. Used by `config validate`, which reports a newer-than-
136
+ * supported `schemaVersion` as a finding rather than aborting on it.
99
137
  */
100
- export function defaultConfigPath() {
101
- const base = process.env.XDG_CONFIG_HOME
102
- ?? process.env.APPDATA
103
- ?? join(homedir(), '.config');
104
- return join(base, 'btcr2', 'config.json');
138
+ export function parseConfigFileRaw(path) {
139
+ let content;
140
+ try {
141
+ content = readFileSync(path, 'utf-8');
142
+ }
143
+ catch (error) {
144
+ if (error.code === 'ENOENT')
145
+ return undefined;
146
+ throw new CLIError(`Failed to read config file at ${path}: ${error.message}`, 'CONFIG_READ_ERROR', { path });
147
+ }
148
+ try {
149
+ return JSON.parse(content);
150
+ }
151
+ catch (error) {
152
+ throw new CLIError(`Config file at ${path} is not valid JSON: ${error.message}. `
153
+ + 'Fix the file by hand; the CLI will not overwrite it while it is unparseable.', 'CONFIG_PARSE_ERROR', { path });
154
+ }
105
155
  }
106
156
  /**
107
- * Reads and parses a config file. Returns `undefined` if the file does
108
- * not exist or cannot be parsed.
157
+ * Reads and parses a config file. Returns `undefined` only when the file is
158
+ * genuinely absent (ENOENT), so callers can safely treat "no file" as "use
159
+ * defaults". Any other read failure, and any JSON parse failure, throws a
160
+ * {@link CLIError} that names the file, rather than silently degrading to the
161
+ * public network defaults. A file written by a newer CLI (higher `schemaVersion`)
162
+ * is also refused.
109
163
  */
110
164
  export function readConfigFile(path) {
111
- try {
112
- const content = readFileSync(path, 'utf-8');
113
- return JSON.parse(content);
114
- }
115
- catch {
165
+ const parsed = parseConfigFileRaw(path);
166
+ if (parsed === undefined)
116
167
  return undefined;
168
+ migrateConfigShape(parsed, path);
169
+ return parsed;
170
+ }
171
+ /**
172
+ * Validates a parsed config's `schemaVersion` against {@link CONFIG_SCHEMA_VERSION}
173
+ * and brings an older shape up to the current version in place. A file written by
174
+ * a newer CLI is refused so today's assumptions are never applied blindly to an
175
+ * unknown shape. An absent version is treated as the earliest and migrated
176
+ * forward. No structural migrations are registered yet (the schema is at version
177
+ * 1); future versions register their transforms here in ascending order.
178
+ */
179
+ function migrateConfigShape(raw, path) {
180
+ const version = typeof raw.schemaVersion === 'number' ? raw.schemaVersion : 0;
181
+ if (version > CONFIG_SCHEMA_VERSION) {
182
+ throw new CLIError(`Config file at ${path} has schemaVersion ${version}, but this CLI supports up to `
183
+ + `${CONFIG_SCHEMA_VERSION}. Upgrade the btcr2 CLI to read it.`, 'CONFIG_SCHEMA_VERSION_ERROR', { path, fileVersion: version, supported: CONFIG_SCHEMA_VERSION });
117
184
  }
118
185
  }
119
186
  /**
@@ -133,26 +200,118 @@ export function profileToOverrides(config, profileName) {
133
200
  casRpcUrl: profile.cas?.rpcUrl,
134
201
  };
135
202
  }
203
+ /**
204
+ * Resolves the active profile name and the network it targets, shared by
205
+ * {@link resolveDefaultNetwork} and {@link resolveConnectionConfig} so the two
206
+ * can never disagree about which profile is active or which network it means.
207
+ *
208
+ * The active profile name is the explicit `--profile` flag, else the config
209
+ * file's `defaults.profile`. The network is the profile's own `network` field
210
+ * when set to a supported value, else the profile name itself when it is a
211
+ * network name (the historical convention). A profile that declares no network
212
+ * and is not named after one yields `network: undefined`.
213
+ */
214
+ export function resolveActiveProfile(file, overrides) {
215
+ const name = blankToUndef(overrides?.profile) ?? file?.defaults?.profile;
216
+ if (!name)
217
+ return { name: undefined, network: undefined };
218
+ const declared = file?.profiles?.[name]?.network;
219
+ const network = (declared && SUPPORTED_NETWORKS.includes(declared))
220
+ ? declared
221
+ : (SUPPORTED_NETWORKS.includes(name) ? name : undefined);
222
+ return { name, network };
223
+ }
136
224
  /**
137
225
  * Resolves the default Bitcoin network for offline identifier creation when no
138
226
  * `--network` flag is given. Resolution order: the config file's
139
- * `defaults.network`, then an active profile named for a network (an explicit
140
- * `--profile` flag or `defaults.profile`), then `regtest` as the development
227
+ * `defaults.network`, then the active profile's network (its explicit `network`
228
+ * field, else its network-derived name), then `regtest` as the development
141
229
  * fallback. Generation itself is offline; this only fixes which network the
142
230
  * identifier encodes.
143
231
  */
144
232
  export function resolveDefaultNetwork(overrides) {
145
- const configPath = overrides?.config ?? defaultConfigPath();
233
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
146
234
  const file = readConfigFile(configPath);
147
235
  const explicit = file?.defaults?.network;
148
236
  if (explicit && SUPPORTED_NETWORKS.includes(explicit))
149
237
  return explicit;
150
- const profile = overrides?.profile ?? file?.defaults?.profile;
151
- if (profile && SUPPORTED_NETWORKS.includes(profile)) {
152
- return profile;
153
- }
238
+ const { network } = resolveActiveProfile(file, overrides);
239
+ if (network)
240
+ return network;
154
241
  return 'regtest';
155
242
  }
243
+ /**
244
+ * Reports a coherence conflict between the network a `create` run is about to
245
+ * encode and the network the active profile declares, so the CLI can warn
246
+ * instead of silently minting an identifier on one network while wiring
247
+ * endpoints for another. Returns `undefined` when the active profile declares
248
+ * no network or agrees with the one being encoded.
249
+ */
250
+ export function profileNetworkMismatch(network, overrides) {
251
+ // This drives only a warning, so a malformed config must not break an otherwise
252
+ // offline `create`; a genuinely broken config is still surfaced loudly by any
253
+ // command that actually resolves a connection.
254
+ let file;
255
+ try {
256
+ file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
257
+ }
258
+ catch {
259
+ return undefined;
260
+ }
261
+ const { name, network: declared } = resolveActiveProfile(file, overrides);
262
+ if (name && declared && declared !== network)
263
+ return { profile: name, declared };
264
+ return undefined;
265
+ }
266
+ /**
267
+ * Resolves the effective output format: the `-o/--output` flag, then the
268
+ * `BTCR2_OUTPUT` environment variable, then the config file's `defaults.output`,
269
+ * then `'text'`. A malformed config never blocks output resolution (the command's
270
+ * own read path surfaces it); output format falls back to `'text'` instead.
271
+ */
272
+ export function resolveOutputFormat(options) {
273
+ const candidates = [
274
+ blankToUndef(options.output),
275
+ process.env.BTCR2_OUTPUT || undefined,
276
+ ];
277
+ try {
278
+ candidates.push(readConfigFile(options.config ?? defaultConfigPath(options))?.defaults?.output);
279
+ }
280
+ catch {
281
+ // Output format is cosmetic; a broken config is reported by the command
282
+ // itself rather than aborting here (which would block a recovery command).
283
+ }
284
+ for (const candidate of candidates) {
285
+ if (candidate === 'json' || candidate === 'text')
286
+ return candidate;
287
+ }
288
+ return 'text';
289
+ }
290
+ /**
291
+ * Resolves the RPC credential unit atomically: the highest-precedence layer that
292
+ * supplies a url, else the highest that supplies a username or password. url,
293
+ * user, and pass therefore always come from one layer, so a host from one layer
294
+ * is never handed another layer's credentials (ADR 074). When no layer supplies a
295
+ * url, the credentials still resolve (so they reach the SDK's per-network default
296
+ * host, e.g. regtest's, without the url being restated). Returns `undefined` when
297
+ * no layer supplies a url or a credential.
298
+ */
299
+ function resolveRpcUnit(overrides, env, fileOverrides) {
300
+ const layers = [
301
+ { src: 'flag', url: overrides?.btcRpcUrl, user: overrides?.btcRpcUser, pass: overrides?.btcRpcPass },
302
+ { src: 'env', url: env?.btcRpcUrl, user: env?.btcRpcUser, pass: env?.btcRpcPass },
303
+ { src: 'file', url: fileOverrides?.btcRpcUrl, user: fileOverrides?.btcRpcUser, pass: fileOverrides?.btcRpcPass },
304
+ ];
305
+ const withUrl = layers.find(l => blankToUndef(l.url) !== undefined);
306
+ if (withUrl) {
307
+ return { src: withUrl.src, url: blankToUndef(withUrl.url), user: blankToUndef(withUrl.user), pass: blankToUndef(withUrl.pass) };
308
+ }
309
+ const withCreds = layers.find(l => blankToUndef(l.user) !== undefined || blankToUndef(l.pass) !== undefined);
310
+ if (withCreds) {
311
+ return { src: withCreds.src, url: undefined, user: blankToUndef(withCreds.user), pass: blankToUndef(withCreds.pass) };
312
+ }
313
+ return undefined;
314
+ }
156
315
  /**
157
316
  * Resolves the Bitcoin and CAS connection config for a network by merging,
158
317
  * in precedence order, CLI flags, environment variables, and the config-file
@@ -164,47 +323,331 @@ export function resolveDefaultNetwork(overrides) {
164
323
  * When no `--profile` is given, the network name is used as the profile key
165
324
  * (e.g. a regtest DID auto-selects the `"regtest"` profile).
166
325
  */
167
- function resolveConnectionConfig(network, overrides) {
326
+ export function resolveConnectionConfig(network, overrides) {
168
327
  if (!network)
169
328
  return {};
170
- // Layer 1: Config file profile (lowest precedence of the three override layers)
171
- const configPath = overrides?.config ?? defaultConfigPath();
329
+ // Layer 1: Config file profile (lowest precedence of the three override layers).
330
+ // The active-profile name is resolved through the same shared helper as
331
+ // resolveDefaultNetwork so the two cannot disagree about which profile is live.
332
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
172
333
  const file = readConfigFile(configPath);
173
- const profileName = overrides?.profile ?? file?.defaults?.profile ?? network;
334
+ const { name: activeProfile } = resolveActiveProfile(file, overrides);
335
+ const profileName = activeProfile ?? network;
174
336
  const fileOverrides = file ? profileToOverrides(file, profileName) : {};
175
337
  // Layer 2: Environment variables
176
338
  const env = readEnvOverrides();
177
- // Merge: CLI flags -> env vars -> config file -> (network defaults handled by BitcoinConnection)
178
- const merged = {
179
- btcRest: overrides?.btcRest ?? env.btcRest ?? fileOverrides.btcRest,
180
- btcRpcUrl: overrides?.btcRpcUrl ?? env.btcRpcUrl ?? fileOverrides.btcRpcUrl,
181
- btcRpcUser: overrides?.btcRpcUser ?? env.btcRpcUser ?? fileOverrides.btcRpcUser,
182
- btcRpcPass: overrides?.btcRpcPass ?? env.btcRpcPass ?? fileOverrides.btcRpcPass,
183
- casGateway: overrides?.casGateway ?? env.casGateway ?? fileOverrides.casGateway,
184
- casRpcUrl: overrides?.casRpcUrl ?? env.casRpcUrl ?? fileOverrides.casRpcUrl,
185
- };
339
+ // Blank-aware precedence merge: CLI flag -> env var -> config file. A blank at
340
+ // any layer defers to the next instead of masking it (mirrors the env layer's
341
+ // `|| undefined`), so an empty flag or profile field no longer silently reverts
342
+ // resolution to the SDK network default.
343
+ const pick = (flag, envVal, fileVal) => blankToUndef(flag) ?? blankToUndef(envVal) ?? blankToUndef(fileVal);
344
+ const profileBtc = file?.profiles?.[profileName]?.btc;
345
+ const profileCas = file?.profiles?.[profileName]?.cas;
186
346
  const btc = { network };
187
- if (merged.btcRest) {
188
- btc.rest = { host: merged.btcRest };
347
+ // REST host and headers. Headers apply even without a host override, layering
348
+ // onto the per-network default host inside the api's config merge, so an
349
+ // authenticated Esplora/mempool endpoint can be reached with the default host.
350
+ const btcRest = pick(overrides?.btcRest, env.btcRest, fileOverrides.btcRest);
351
+ const restHeaders = mergeHeaders(profileBtc?.headers, parseHeaderList(overrides?.btcRestHeader, '--btc-rest-header'));
352
+ if (btcRest || restHeaders) {
353
+ btc.rest = { ...(btcRest ? { host: btcRest } : {}), ...(restHeaders ? { headers: restHeaders } : {}) };
189
354
  }
190
- if (merged.btcRpcUrl) {
355
+ // Resolve the RPC endpoint as one atomic credential unit (url + user + pass from
356
+ // one layer), plus the orthogonal wallet and header augmentations.
357
+ const rpcUnit = resolveRpcUnit(overrides, env, fileOverrides);
358
+ const rpcWallet = pick(overrides?.btcRpcWallet, undefined, profileBtc?.wallet);
359
+ const rpcHeaders = mergeHeaders(profileBtc?.rpcHeaders, parseHeaderList(overrides?.btcRpcHeader, '--btc-rpc-header'));
360
+ // Build an RPC config only when a host will actually exist to talk to: either
361
+ // the unit supplies a url, or the network has a default RPC host (regtest).
362
+ // Wallet/header (or credential) knobs alone with no host would otherwise point
363
+ // a phantom client at the default 127.0.0.1:8332 and, on public networks, flip
364
+ // the connection to "has RPC" spuriously (and could leak a header credential
365
+ // meant for a remote proxy). The pass-file fallback is read lazily inside this
366
+ // block, so a set-but-unreadable BTCR2_BTC_RPC_PASS_FILE never aborts a command
367
+ // that uses no RPC at all.
368
+ const networkHasDefaultRpc = DEFAULT_BITCOIN_NETWORK_CONFIG[network].rpc !== undefined;
369
+ const wantsRpc = rpcUnit !== undefined || rpcWallet !== undefined || rpcHeaders !== undefined;
370
+ const hasRpcHost = rpcUnit?.url !== undefined || networkHasDefaultRpc;
371
+ if (wantsRpc && hasRpcHost) {
372
+ const password = resolveSecretRef(rpcUnit?.pass) ?? readRpcPassFile();
191
373
  btc.rpc = {
192
- host: merged.btcRpcUrl,
193
- username: merged.btcRpcUser,
194
- password: merged.btcRpcPass,
374
+ ...(rpcUnit?.url !== undefined ? { host: rpcUnit.url } : {}),
375
+ ...(rpcUnit?.user !== undefined ? { username: rpcUnit.user } : {}),
376
+ ...(password !== undefined ? { password } : {}),
377
+ ...(rpcWallet ? { wallet: rpcWallet } : {}),
378
+ ...(rpcHeaders ? { headers: rpcHeaders } : {}),
195
379
  };
196
380
  }
381
+ // Bitcoin request timeout. No default: honored only when explicitly set, so
382
+ // callers that rely on unbounded waits are unaffected (ADR 076).
383
+ const btcTimeout = resolveTimeout(overrides?.btcTimeout, process.env[ENV_VARS.BTC_TIMEOUT], profileBtc?.timeoutMs, '--btc-timeout', 1);
384
+ if (btcTimeout !== undefined)
385
+ btc.timeoutMs = btcTimeout;
197
386
  // A configured RPC endpoint is writable and takes precedence over the
198
387
  // read-only gateway (matching the api's CasConfig priority: rpcUrl > gateway).
199
388
  // Both may be set; the api selects one executor from them.
389
+ const casGateway = pick(overrides?.casGateway, env.casGateway, fileOverrides.casGateway);
390
+ const casRpcUrl = pick(overrides?.casRpcUrl, env.casRpcUrl, fileOverrides.casRpcUrl);
391
+ const casTimeout = resolveTimeout(overrides?.casTimeout, process.env[ENV_VARS.CAS_TIMEOUT], profileCas?.timeoutMs, '--cas-timeout');
200
392
  const cas = {};
201
- if (merged.casGateway)
202
- cas.gateway = merged.casGateway;
203
- if (merged.casRpcUrl)
204
- cas.rpcUrl = merged.casRpcUrl;
205
- const hasCas = merged.casGateway || merged.casRpcUrl;
393
+ if (casGateway)
394
+ cas.gateway = casGateway;
395
+ if (casRpcUrl)
396
+ cas.rpcUrl = casRpcUrl;
397
+ if (casTimeout !== undefined) {
398
+ cas.timeoutMs = casTimeout;
399
+ // A timeout needs an endpoint to attach to. When none is configured, fall
400
+ // back to the same default gateway the api would otherwise apply, so the
401
+ // timeout is honored rather than dropped (the api only defaults the gateway
402
+ // when the whole cas config is absent).
403
+ if (!casGateway && !casRpcUrl)
404
+ cas.gateway = DEFAULT_CAS_GATEWAY;
405
+ }
406
+ const hasCas = casGateway || casRpcUrl || casTimeout !== undefined;
206
407
  return { btc, ...(hasCas && { cas }) };
207
408
  }
409
+ /**
410
+ * Resolves a millisecond timeout from a flag string, an env string, then a
411
+ * config-file number, in precedence order. Returns `undefined` when none is set
412
+ * (preserving unbounded behavior). Throws a {@link CLIError} for a value below
413
+ * `min` or not a finite number. `min` is `0` for CAS (where `0` disables the
414
+ * timeout) and `1` for the Bitcoin timeout (where `0` would abort every request
415
+ * immediately, which is never intended).
416
+ */
417
+ function resolveTimeout(flag, envVal, fileVal, flagName = 'timeout', min = 0) {
418
+ const raw = blankToUndef(flag) ?? blankToUndef(envVal) ?? (typeof fileVal === 'number' ? String(fileVal) : undefined);
419
+ if (raw === undefined)
420
+ return undefined;
421
+ const ms = Number(raw);
422
+ if (!Number.isFinite(ms) || ms < min) {
423
+ throw new CLIError(`Invalid ${flagName} value "${raw}": expected a number of milliseconds >= ${min}.`, 'INVALID_ARGUMENT_ERROR', { value: raw });
424
+ }
425
+ return ms;
426
+ }
427
+ /**
428
+ * Parses repeatable `Key: Value` header flag values into a header map. Returns
429
+ * `undefined` for an empty list. Throws a {@link CLIError} for an entry missing a
430
+ * colon or with an empty key.
431
+ */
432
+ export function parseHeaderList(list, flagName = '--header') {
433
+ if (!list || list.length === 0)
434
+ return undefined;
435
+ const headers = {};
436
+ for (const entry of list) {
437
+ const idx = entry.indexOf(':');
438
+ const key = idx === -1 ? '' : entry.slice(0, idx).trim();
439
+ if (idx === -1 || key === '') {
440
+ throw new CLIError(`Invalid ${flagName} "${entry}": expected "Key: Value".`, 'INVALID_ARGUMENT_ERROR', { header: entry });
441
+ }
442
+ headers[key] = entry.slice(idx + 1).trim();
443
+ }
444
+ return headers;
445
+ }
446
+ /**
447
+ * Merges a base header map (from the config-file profile) with an override map
448
+ * (from flags), with the override winning per key. Returns `undefined` when both
449
+ * are absent so callers can skip attaching an empty header map.
450
+ */
451
+ function mergeHeaders(base, override) {
452
+ if (!base && !override)
453
+ return undefined;
454
+ return { ...base, ...override };
455
+ }
456
+ /** Environment variable naming a file whose contents are the Bitcoin Core RPC password. */
457
+ export const ENV_RPC_PASS_FILE = 'BTCR2_BTC_RPC_PASS_FILE';
458
+ /** Removes at most one trailing newline, matching the keystore-passphrase normalization. */
459
+ function trimTrailingNewline(value) {
460
+ return value.replace(/\r?\n$/, '');
461
+ }
462
+ /** Reads a secret file, throwing a {@link CLIError} (not a raw Node error) that names the path and source. */
463
+ function readSecretFile(path, source) {
464
+ try {
465
+ return trimTrailingNewline(readFileSync(path, 'utf-8'));
466
+ }
467
+ catch (error) {
468
+ throw new CLIError(`Could not read the RPC password ${source} at ${path}: ${error.message}`, 'CONFIG_READ_ERROR', { path });
469
+ }
470
+ }
471
+ /**
472
+ * Resolves an RPC-password secret reference to its literal value: `env:<VAR>`
473
+ * reads the named environment variable, `file:<path>` reads the file, and any
474
+ * other value is returned as-is. A trailing newline is trimmed from file/env
475
+ * sources so a secret written by `echo` matches an inline value (ADR 077).
476
+ */
477
+ export function resolveSecretRef(value) {
478
+ if (value === undefined)
479
+ return undefined;
480
+ if (value.startsWith('env:')) {
481
+ const fromEnv = process.env[value.slice(4)];
482
+ return fromEnv === undefined ? undefined : trimTrailingNewline(fromEnv);
483
+ }
484
+ if (value.startsWith('file:')) {
485
+ return readSecretFile(value.slice(5), 'file reference');
486
+ }
487
+ return value;
488
+ }
489
+ /** Reads the RPC password from an {@link ENV_RPC_PASS_FILE}-named file, if set. */
490
+ function readRpcPassFile() {
491
+ const path = process.env[ENV_RPC_PASS_FILE];
492
+ if (!path)
493
+ return undefined;
494
+ return readSecretFile(path, `file named by ${ENV_RPC_PASS_FILE}`);
495
+ }
496
+ /**
497
+ * Resolves the beacon {@link BroadcastOptions} for an update/deactivate from the
498
+ * fee-rate and change-address knobs, following the CLI precedence chain.
499
+ *
500
+ * - Fee rate: `--fee-rate` flag, then `BTCR2_FEE_RATE`, then profile
501
+ * `btc.feeRate`. A positive sats/vByte value wrapped in a `StaticFeeEstimator`.
502
+ * - Change address: `--change-address` flag, then profile `btc.changeAddress`
503
+ * (no env, since a change address is DID/network-specific). Validated against
504
+ * the DID network by the beacon at broadcast time.
505
+ *
506
+ * Returns `undefined` when neither is set, so the SDK defaults (5 sat/vB, change
507
+ * back to the beacon address) still apply.
508
+ */
509
+ export function resolveBroadcastOptions(network, overrides, flags) {
510
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
511
+ const { name: activeProfile } = resolveActiveProfile(file, overrides);
512
+ const profileBtc = file?.profiles?.[activeProfile ?? network]?.btc;
513
+ const options = {};
514
+ const feeRateRaw = blankToUndef(flags.feeRate)
515
+ ?? blankToUndef(process.env[ENV_VARS.FEE_RATE])
516
+ ?? (typeof profileBtc?.feeRate === 'number' ? String(profileBtc.feeRate) : undefined);
517
+ if (feeRateRaw !== undefined)
518
+ options.feeEstimator = new StaticFeeEstimator(parseFeeRate(feeRateRaw));
519
+ const changeAddress = blankToUndef(flags.changeAddress) ?? blankToUndef(profileBtc?.changeAddress);
520
+ if (changeAddress)
521
+ options.changeAddress = changeAddress;
522
+ return options.feeEstimator || options.changeAddress ? options : undefined;
523
+ }
524
+ /** Parses a positive sats/vByte fee rate, throwing a {@link CLIError} otherwise. */
525
+ function parseFeeRate(raw) {
526
+ const rate = Number(raw);
527
+ if (!Number.isFinite(rate) || rate <= 0) {
528
+ throw new CLIError(`Invalid --fee-rate "${raw}": expected a positive number of sats per vByte.`, 'INVALID_ARGUMENT_ERROR', { value: raw });
529
+ }
530
+ return rate;
531
+ }
532
+ /**
533
+ * Resolves the effective connection config with provenance for `config effective`.
534
+ * The btc values are read back from the constructed api (so SDK network defaults
535
+ * are reflected), the cas and timeout values from {@link resolveConnectionConfig},
536
+ * and each `source` is derived by the same precedence order the merge uses.
537
+ */
538
+ export function resolveEffectiveConfig(network, overrides) {
539
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
540
+ const { name: activeProfile } = resolveActiveProfile(file, overrides);
541
+ const profileName = activeProfile ?? network;
542
+ const fileOv = file ? profileToOverrides(file, profileName) : {};
543
+ const profileBtc = file?.profiles?.[profileName]?.btc;
544
+ const profileCas = file?.profiles?.[profileName]?.cas;
545
+ const env = readEnvOverrides();
546
+ const api = defaultApiFactory(network, overrides);
547
+ const restCfg = api.btc.connection.rest.config;
548
+ const rpcCfg = api.btc.connection.rpc?.config;
549
+ const conn = resolveConnectionConfig(network, overrides);
550
+ const src = (flag, envVal, fileVal) => blankToUndef(flag) !== undefined ? 'flag'
551
+ : blankToUndef(envVal) !== undefined ? 'env'
552
+ : (fileVal !== undefined && fileVal !== null && String(fileVal).trim() !== '') ? 'file'
553
+ : 'default';
554
+ // CAS has no client to read back from; derive its resolved endpoint from the
555
+ // connection resolver, defaulting the gateway the way the api would.
556
+ const casGatewayVal = conn.cas?.gateway ?? (conn.cas?.rpcUrl ? undefined : DEFAULT_CAS_GATEWAY);
557
+ // RPC url/user/pass provenance follows the atomic-credential unit, so a value's
558
+ // reported source is the layer the merge actually bound it to (never an
559
+ // independent per-field guess that could disagree with the resolver). A password
560
+ // taken from BTCR2_BTC_RPC_PASS_FILE when the unit supplied none is env-sourced.
561
+ const rpcUnit = resolveRpcUnit(overrides, env, fileOv);
562
+ const rpcSrc = rpcUnit?.src ?? 'default';
563
+ const passFromFile = rpcUnit?.pass === undefined
564
+ && process.env[ENV_RPC_PASS_FILE] !== undefined
565
+ && rpcCfg?.password !== undefined;
566
+ return {
567
+ network,
568
+ profile: activeProfile,
569
+ btc: {
570
+ rest: { value: restCfg.host, source: src(overrides?.btcRest, env.btcRest, fileOv.btcRest) },
571
+ rpcUrl: { value: rpcCfg?.host, source: rpcUnit?.url !== undefined ? rpcSrc : 'default' },
572
+ rpcUser: { value: rpcCfg?.username, source: rpcUnit?.user !== undefined ? rpcSrc : 'default' },
573
+ rpcPass: { value: rpcCfg?.password, source: rpcUnit?.pass !== undefined ? rpcSrc : (passFromFile ? 'env' : 'default') },
574
+ rpcWallet: { value: rpcCfg?.wallet, source: src(overrides?.btcRpcWallet, undefined, profileBtc?.wallet) },
575
+ timeoutMs: { value: conn.btc?.timeoutMs, source: src(overrides?.btcTimeout, process.env[ENV_VARS.BTC_TIMEOUT], profileBtc?.timeoutMs) },
576
+ },
577
+ cas: {
578
+ gateway: { value: casGatewayVal, source: src(overrides?.casGateway, env.casGateway, fileOv.casGateway) },
579
+ rpcUrl: { value: conn.cas?.rpcUrl, source: src(overrides?.casRpcUrl, env.casRpcUrl, fileOv.casRpcUrl) },
580
+ timeoutMs: { value: conn.cas?.timeoutMs, source: src(overrides?.casTimeout, process.env[ENV_VARS.CAS_TIMEOUT], profileCas?.timeoutMs) },
581
+ },
582
+ };
583
+ }
584
+ /** Default per-probe timeout (ms) for `config doctor`. */
585
+ const DOCTOR_PROBE_TIMEOUT_MS = 5000;
586
+ /** Fetches a URL with a bounded timeout, reporting reachability rather than throwing. */
587
+ async function probeEndpoint(endpoint, target, url, opts) {
588
+ try {
589
+ const res = await fetch(url, {
590
+ method: opts?.method ?? 'GET',
591
+ headers: opts?.headers,
592
+ signal: AbortSignal.timeout(DOCTOR_PROBE_TIMEOUT_MS),
593
+ });
594
+ return res.ok
595
+ ? { endpoint, target, ok: true }
596
+ : { endpoint, target, ok: false, detail: `HTTP ${res.status}` };
597
+ }
598
+ catch (error) {
599
+ return { endpoint, target, ok: false, detail: error.message };
600
+ }
601
+ }
602
+ /** Races a promise against a timeout so a stalled RPC call cannot hang `doctor`. */
603
+ function withProbeTimeout(promise, ms) {
604
+ let timer;
605
+ const timeout = new Promise((_, reject) => {
606
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
607
+ });
608
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
609
+ }
610
+ /**
611
+ * Probes reachability of the resolved endpoints for `config doctor`: a
612
+ * lightweight REST call against btc-rest, a `getblockchaininfo` against btc-rpc
613
+ * when configured, and a reachability check against the resolved CAS. Also
614
+ * surfaces the profile/network coherence warning. Reads and touches the network;
615
+ * never writes.
616
+ */
617
+ export async function runDoctor(network, overrides) {
618
+ const api = defaultApiFactory(network, overrides);
619
+ const checks = [];
620
+ const restHost = api.btc.connection.rest.config.host.replace(/\/+$/, '');
621
+ checks.push(await probeEndpoint('btc-rest', restHost, `${restHost}/blocks/tip/height`, { headers: api.btc.connection.rest.config.headers }));
622
+ const rpc = api.btc.connection.rpc;
623
+ if (rpc) {
624
+ const target = rpc.config.host ?? '(default rpc)';
625
+ try {
626
+ await withProbeTimeout(rpc.getBlockchainInfo(), DOCTOR_PROBE_TIMEOUT_MS);
627
+ checks.push({ endpoint: 'btc-rpc', target, ok: true });
628
+ }
629
+ catch (error) {
630
+ checks.push({ endpoint: 'btc-rpc', target, ok: false, detail: error.message });
631
+ }
632
+ }
633
+ // A writable IPFS RPC (Kubo) answers only POST, so a bare GET would falsely
634
+ // report a healthy node as down; probe its version endpoint with POST. A
635
+ // read-only gateway answers a plain GET on its base URL.
636
+ const conn = resolveConnectionConfig(network, overrides);
637
+ if (conn.cas?.rpcUrl) {
638
+ const base = conn.cas.rpcUrl.replace(/\/+$/, '');
639
+ checks.push(await probeEndpoint('cas', conn.cas.rpcUrl, `${base}/api/v0/version`, { method: 'POST' }));
640
+ }
641
+ else {
642
+ const gateway = (conn.cas?.gateway ?? DEFAULT_CAS_GATEWAY).replace(/\/+$/, '');
643
+ checks.push(await probeEndpoint('cas', gateway, gateway));
644
+ }
645
+ const mismatch = profileNetworkMismatch(network, overrides);
646
+ return {
647
+ checks,
648
+ ...(mismatch ? { coherence: { profile: mismatch.profile, declared: mismatch.declared, encoding: network } } : {}),
649
+ };
650
+ }
208
651
  /**
209
652
  * Default {@link ApiFactory} backed by network defaults from
210
653
  * `@did-btcr2/bitcoin` (mempool.space for public networks, localhost for
@@ -226,10 +669,91 @@ export function defaultApiFactory(network, overrides) {
226
669
  */
227
670
  function buildKeystoreKms(overrides) {
228
671
  return new FileBackedKeyManager({
229
- path: overrides?.keystore ?? defaultKeystorePath(),
230
- getPassphrase: () => acquirePassphrase({ passphraseFile: overrides?.passphraseFile }),
672
+ path: resolveKeystorePath(overrides),
673
+ // The store decides when to confirm: it passes `confirm: true` only while
674
+ // establishing a fresh keystore's passphrase, so a first-key typo is caught
675
+ // by a second entry (ADR 080). confirm is a no-op for env/file sources.
676
+ getPassphrase: (opts) => acquirePassphrase({ passphraseFile: overrides?.passphraseFile, confirm: opts?.confirm }),
231
677
  });
232
678
  }
679
+ /**
680
+ * The protection mode of the resolved keystore, read without decrypting or
681
+ * prompting: `encrypted`, `dev` (plaintext), or `absent`. Used by `keystore
682
+ * status`, `config path`, and the mainnet guard.
683
+ */
684
+ export function resolveKeystoreProtection(overrides) {
685
+ return keystoreProtection(resolveKeystorePath(overrides));
686
+ }
687
+ /**
688
+ * Hard-refuses using an unencrypted dev keystore for a mainnet operation (ADR
689
+ * 080). A plaintext key must never sign or seal a `bitcoin` did:btcr2; the check
690
+ * reads only the keystore's protection header, so it never decrypts or prompts.
691
+ * A no-op for every other network and for encrypted/absent keystores.
692
+ */
693
+ export function assertKeystoreAllowedForNetwork(network, overrides) {
694
+ if (network !== 'bitcoin')
695
+ return;
696
+ if (resolveKeystoreProtection(overrides) !== 'dev')
697
+ return;
698
+ const path = resolveKeystorePath(overrides);
699
+ throw new CLIError(`Refusing a mainnet (bitcoin) operation with the unencrypted dev keystore at ${path}. `
700
+ + 'Dev keystores hold plaintext keys and are for testnet/regtest throwaway material only. '
701
+ + 'Establish an encrypted keystore (btcr2 keystore init) for mainnet keys.', 'DEV_KEYSTORE_MAINNET_ERROR', { path, network });
702
+ }
703
+ /**
704
+ * Resolves the keystore file path: the `--keystore` flag, else the active
705
+ * profile's `identity.keystore`, else the default `<home>/keystore.json` (ADR
706
+ * 079). The flag always wins over the profile default and never reads the config.
707
+ *
708
+ * A malformed config aborts loudly by default so a keystore-mutating command
709
+ * never silently reads or writes the wrong store. Pass `lenient: true` only for
710
+ * diagnostic/recovery commands (`config path`, `keystore status`) that must still
711
+ * report a path instead of crashing on the very config you ran them to fix; those
712
+ * fall back to the home default when the profile identity cannot be read.
713
+ */
714
+ export function resolveKeystorePath(overrides, options) {
715
+ // The flag wins outright and short-circuits before any config read. A blank
716
+ // flag defers to the profile, and a blank profile `identity.keystore` defers to
717
+ // the default, so neither resolves the keystore to an empty path.
718
+ const fromFlag = blankToUndef(overrides?.keystore);
719
+ if (fromFlag)
720
+ return fromFlag;
721
+ let identity;
722
+ try {
723
+ identity = activeProfileIdentity(overrides);
724
+ }
725
+ catch (error) {
726
+ if (!options?.lenient)
727
+ throw error;
728
+ }
729
+ return blankToUndef(identity?.keystore) ?? defaultKeystorePath(overrides);
730
+ }
731
+ /**
732
+ * Reads the active profile's `identity` block (keystore + default signing key),
733
+ * or `undefined` when no profile is active. A profile is active only when
734
+ * selected by `--profile` or the config's `defaults.profile`.
735
+ */
736
+ function activeProfileIdentity(overrides) {
737
+ // Intentionally propagates a malformed-config error rather than swallowing it.
738
+ // This feeds resolveKeystorePath for keystore-mutating commands (key generate/
739
+ // import, keystore init/change-passphrase) and the mainnet dev-keystore guard,
740
+ // so a broken config must abort loudly instead of silently resolving to the
741
+ // default keystore and stranding key material there. Diagnostic-only commands
742
+ // opt into a graceful fallback via resolveKeystorePath's `lenient` option; do
743
+ // not add a try/catch here (it would re-hide the keystore misdirection).
744
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
745
+ const { name } = resolveActiveProfile(file, overrides);
746
+ return name ? file?.profiles?.[name]?.identity : undefined;
747
+ }
748
+ /**
749
+ * Resolves the signing-key reference for update/deactivate: the `--signing-key`
750
+ * flag, else the active profile's `identity.default`, else `undefined` (letting
751
+ * the KMS fall back to its active key). The flag always wins over the profile
752
+ * default, consistent with the flag -> profile precedence used elsewhere.
753
+ */
754
+ export function resolveSigningKeyRef(overrides) {
755
+ return blankToUndef(overrides?.signingKey) ?? blankToUndef(activeProfileIdentity(overrides)?.default);
756
+ }
233
757
  /**
234
758
  * Keystore-aware {@link ApiFactory} for commands that need a signing identity
235
759
  * (key management, update, deactivate). Identical to {@link defaultApiFactory}