@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.
- package/README.md +106 -17
- package/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +1160 -131
- package/dist/esm/src/cli.js +29 -5
- package/dist/esm/src/cli.js.map +1 -1
- package/dist/esm/src/commands/config.js +130 -18
- package/dist/esm/src/commands/config.js.map +1 -1
- package/dist/esm/src/commands/create.js +13 -1
- package/dist/esm/src/commands/create.js.map +1 -1
- package/dist/esm/src/commands/deactivate.js +16 -2
- package/dist/esm/src/commands/deactivate.js.map +1 -1
- package/dist/esm/src/commands/index.js +2 -0
- package/dist/esm/src/commands/index.js.map +1 -1
- package/dist/esm/src/commands/init.js +63 -0
- package/dist/esm/src/commands/init.js.map +1 -0
- package/dist/esm/src/commands/keystore.js +81 -0
- package/dist/esm/src/commands/keystore.js.map +1 -0
- package/dist/esm/src/commands/profile.js +6 -4
- package/dist/esm/src/commands/profile.js.map +1 -1
- package/dist/esm/src/commands/update.js +16 -2
- package/dist/esm/src/commands/update.js.map +1 -1
- package/dist/esm/src/config-schema.js +149 -0
- package/dist/esm/src/config-schema.js.map +1 -0
- package/dist/esm/src/config.js +579 -55
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/keystore/file-key-store.js +340 -32
- package/dist/esm/src/keystore/file-key-store.js.map +1 -1
- package/dist/esm/src/keystore/passphrase.js +40 -10
- package/dist/esm/src/keystore/passphrase.js.map +1 -1
- package/dist/esm/src/keystore/paths.js +6 -17
- package/dist/esm/src/keystore/paths.js.map +1 -1
- package/dist/esm/src/output.js +49 -0
- package/dist/esm/src/output.js.map +1 -1
- package/dist/esm/src/paths.js +59 -0
- package/dist/esm/src/paths.js.map +1 -0
- package/dist/esm/src/types.js +11 -0
- package/dist/esm/src/types.js.map +1 -1
- package/dist/types/src/cli.d.ts.map +1 -1
- package/dist/types/src/commands/config.d.ts.map +1 -1
- package/dist/types/src/commands/create.d.ts.map +1 -1
- package/dist/types/src/commands/deactivate.d.ts.map +1 -1
- package/dist/types/src/commands/index.d.ts +2 -0
- package/dist/types/src/commands/index.d.ts.map +1 -1
- package/dist/types/src/commands/init.d.ts +13 -0
- package/dist/types/src/commands/init.d.ts.map +1 -0
- package/dist/types/src/commands/keystore.d.ts +10 -0
- package/dist/types/src/commands/keystore.d.ts.map +1 -0
- package/dist/types/src/commands/profile.d.ts.map +1 -1
- package/dist/types/src/commands/update.d.ts.map +1 -1
- package/dist/types/src/config-schema.d.ts +24 -0
- package/dist/types/src/config-schema.d.ts.map +1 -0
- package/dist/types/src/config.d.ts +252 -14
- package/dist/types/src/config.d.ts.map +1 -1
- package/dist/types/src/keystore/file-key-store.d.ts +86 -10
- package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
- package/dist/types/src/keystore/passphrase.d.ts +16 -1
- package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
- package/dist/types/src/keystore/paths.d.ts +6 -10
- package/dist/types/src/keystore/paths.d.ts.map +1 -1
- package/dist/types/src/output.d.ts +22 -0
- package/dist/types/src/output.d.ts.map +1 -1
- package/dist/types/src/paths.d.ts +54 -0
- package/dist/types/src/paths.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +70 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +32 -4
- package/src/commands/config.ts +143 -18
- package/src/commands/create.ts +16 -1
- package/src/commands/deactivate.ts +24 -2
- package/src/commands/index.ts +2 -0
- package/src/commands/init.ts +74 -0
- package/src/commands/keystore.ts +98 -0
- package/src/commands/profile.ts +6 -4
- package/src/commands/update.ts +24 -2
- package/src/config-schema.ts +178 -0
- package/src/config.ts +752 -58
- package/src/keystore/file-key-store.ts +455 -43
- package/src/keystore/passphrase.ts +48 -8
- package/src/keystore/paths.ts +6 -18
- package/src/output.ts +53 -0
- package/src/paths.ts +79 -0
- package/src/types.ts +34 -0
package/src/config.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { createApi, Identifier, type BitcoinApiConfig, type CasConfig, type DidBtcr2Api } from '@did-btcr2/api';
|
|
1
|
+
import { createApi, DEFAULT_BITCOIN_NETWORK_CONFIG, DEFAULT_CAS_GATEWAY, Identifier, type BitcoinApiConfig, type CasConfig, type DidBtcr2Api } from '@did-btcr2/api';
|
|
2
2
|
import type { KeyManager } from '@did-btcr2/key-manager';
|
|
3
|
+
import { StaticFeeEstimator } from '@did-btcr2/method';
|
|
4
|
+
import type { BroadcastOptions } from '@did-btcr2/method';
|
|
3
5
|
import { readFileSync } from 'node:fs';
|
|
4
|
-
import {
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
6
7
|
import { CLIError } from './error.js';
|
|
7
8
|
import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
|
|
8
9
|
import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
|
|
10
|
+
import { keystoreProtection } from './keystore/file-key-store.js';
|
|
9
11
|
import { defaultKeystorePath } from './keystore/paths.js';
|
|
10
12
|
import { acquirePassphrase } from './keystore/passphrase.js';
|
|
11
|
-
import {
|
|
13
|
+
import { defaultConfigPath } from './paths.js';
|
|
14
|
+
import { blankToUndef, SUPPORTED_NETWORKS, type KeystoreProtectionLabel, type NetworkOption, type OutputFormat } from './types.js';
|
|
15
|
+
|
|
16
|
+
export { defaultConfigPath };
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* Endpoint overrides provided via CLI flags, env vars, or config file.
|
|
@@ -27,12 +32,26 @@ export type ConnectionOverrides = {
|
|
|
27
32
|
casGateway? : string;
|
|
28
33
|
/** IPFS HTTP RPC endpoint for a writable CAS (reads + writes). */
|
|
29
34
|
casRpcUrl? : string;
|
|
35
|
+
/** Bitcoin REST/RPC request timeout in milliseconds (raw flag/env string). */
|
|
36
|
+
btcTimeout? : string;
|
|
37
|
+
/** CAS request timeout in milliseconds (raw flag/env string; `0` disables). */
|
|
38
|
+
casTimeout? : string;
|
|
39
|
+
/** Extra Bitcoin REST headers as raw `Key: Value` flag values (repeatable). */
|
|
40
|
+
btcRestHeader? : string[];
|
|
41
|
+
/** Bitcoin Core RPC wallet name for wallet-scoped RPCs. */
|
|
42
|
+
btcRpcWallet? : string;
|
|
43
|
+
/** Extra Bitcoin Core RPC headers as raw `Key: Value` flag values (repeatable). */
|
|
44
|
+
btcRpcHeader? : string[];
|
|
45
|
+
/** CLI home root from `--home`. Colocates config.json + keystore.json (ADR 079). */
|
|
46
|
+
home? : string;
|
|
30
47
|
config? : string;
|
|
31
48
|
profile? : string;
|
|
32
|
-
/** Keystore file path. Overrides the default
|
|
49
|
+
/** Keystore file path. Overrides the home default `<home>/keystore.json`. */
|
|
33
50
|
keystore? : string;
|
|
34
51
|
/** Path to a file holding the keystore passphrase (for unattended use). */
|
|
35
52
|
passphraseFile? : string;
|
|
53
|
+
/** Signing key reference (URN, fingerprint prefix, or name) from `--signing-key`. */
|
|
54
|
+
signingKey? : string;
|
|
36
55
|
};
|
|
37
56
|
|
|
38
57
|
/**
|
|
@@ -68,17 +87,38 @@ export type ConfigFile = {
|
|
|
68
87
|
output?: OutputFormat;
|
|
69
88
|
};
|
|
70
89
|
profiles?: Record<string, {
|
|
90
|
+
/**
|
|
91
|
+
* The Bitcoin network this profile's endpoints target. Declaring it lets a
|
|
92
|
+
* profile that is not named after a network (e.g. `production`) still fix
|
|
93
|
+
* the network its `create` runs encode, and lets the CLI warn when the
|
|
94
|
+
* network being encoded disagrees with the profile's endpoints.
|
|
95
|
+
*/
|
|
96
|
+
network? : NetworkOption;
|
|
71
97
|
btc?: {
|
|
72
|
-
rest?
|
|
73
|
-
rpcUrl?
|
|
74
|
-
rpcUser?
|
|
75
|
-
rpcPass?
|
|
98
|
+
rest? : string;
|
|
99
|
+
rpcUrl? : string;
|
|
100
|
+
rpcUser? : string;
|
|
101
|
+
rpcPass? : string;
|
|
102
|
+
/** Fee rate in sats/vByte for beacon transactions (update/deactivate). */
|
|
103
|
+
feeRate? : number;
|
|
104
|
+
/** Change address for beacon transactions (ADR 044 unlinkability opt-out). */
|
|
105
|
+
changeAddress? : string;
|
|
106
|
+
/** Request timeout in milliseconds for REST/RPC calls. No default (unbounded). */
|
|
107
|
+
timeoutMs? : number;
|
|
108
|
+
/** Extra headers sent on REST (Esplora) requests, e.g. an API key. */
|
|
109
|
+
headers? : Record<string, string>;
|
|
110
|
+
/** Bitcoin Core wallet name for wallet-scoped RPCs. */
|
|
111
|
+
wallet? : string;
|
|
112
|
+
/** Extra headers sent on Bitcoin Core RPC requests. */
|
|
113
|
+
rpcHeaders? : Record<string, string>;
|
|
76
114
|
};
|
|
77
115
|
cas?: {
|
|
78
116
|
/** IPFS HTTP gateway for CAS reads (read-only). */
|
|
79
117
|
gateway?: string;
|
|
80
118
|
/** IPFS HTTP RPC endpoint for a writable CAS (reads + writes). */
|
|
81
119
|
rpcUrl?: string;
|
|
120
|
+
/** Request timeout in milliseconds for CAS operations. Default 30000; `0` disables. */
|
|
121
|
+
timeoutMs?: number;
|
|
82
122
|
};
|
|
83
123
|
/** Signing identity references. Never embeds key material; the secret lives in the keystore. */
|
|
84
124
|
identity?: {
|
|
@@ -95,6 +135,11 @@ export const CONFIG_SCHEMA_VERSION = 1;
|
|
|
95
135
|
* Read-modify-write a config file, preserving unknown keys. Reads the raw JSON
|
|
96
136
|
* (so keys outside {@link ConfigFile} survive a rewrite), applies `mutate`,
|
|
97
137
|
* stamps the schema version, and writes atomically (file 0600, dir 0700).
|
|
138
|
+
*
|
|
139
|
+
* A file that exists but cannot be parsed makes {@link readConfigFile} throw, so
|
|
140
|
+
* a write never starts from `{}` over a malformed-but-recoverable file and can
|
|
141
|
+
* never clobber the other profiles and defaults it still holds. A genuinely
|
|
142
|
+
* absent file (ENOENT) still starts from `{}`.
|
|
98
143
|
*/
|
|
99
144
|
export function writeConfigFile(path: string, mutate: (raw: Record<string, unknown>) => void): void {
|
|
100
145
|
const raw: Record<string, unknown> = (readConfigFile(path) as Record<string, unknown> | undefined) ?? {};
|
|
@@ -104,6 +149,22 @@ export function writeConfigFile(path: string, mutate: (raw: Record<string, unkno
|
|
|
104
149
|
writeFileAtomic(path, `${JSON.stringify(raw, null, 2)}\n`, 0o600);
|
|
105
150
|
}
|
|
106
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Writes a default config scaffold to `path`: schema version, a `text` output
|
|
154
|
+
* default, and one empty profile per supported network. Shared by `config init`
|
|
155
|
+
* and `btcr2 init` so the seeded config is identical. Writes atomically (file
|
|
156
|
+
* 0600, dir 0700); the caller decides whether to overwrite an existing file.
|
|
157
|
+
*/
|
|
158
|
+
export function writeDefaultConfigFile(path: string): void {
|
|
159
|
+
const scaffold = {
|
|
160
|
+
schemaVersion : CONFIG_SCHEMA_VERSION,
|
|
161
|
+
defaults : { output: 'text' },
|
|
162
|
+
profiles : Object.fromEntries(SUPPORTED_NETWORKS.map(n => [ n, {} ])),
|
|
163
|
+
};
|
|
164
|
+
ensureDir(dirname(path), 0o700);
|
|
165
|
+
writeFileAtomic(path, `${JSON.stringify(scaffold, null, 2)}\n`, 0o600);
|
|
166
|
+
}
|
|
167
|
+
|
|
107
168
|
/** Reads the value at a dotted path (e.g. `profiles.regtest.btc.rest`). */
|
|
108
169
|
export function getConfigPath(config: Record<string, unknown>, path: string): unknown {
|
|
109
170
|
return path.split('.').reduce<unknown>(
|
|
@@ -112,9 +173,20 @@ export function getConfigPath(config: Record<string, unknown>, path: string): un
|
|
|
112
173
|
);
|
|
113
174
|
}
|
|
114
175
|
|
|
176
|
+
/** Dotted-path segments that would let a write reach the prototype chain. */
|
|
177
|
+
const UNSAFE_KEYS = new Set([ '__proto__', 'constructor', 'prototype' ]);
|
|
178
|
+
|
|
179
|
+
/** Rejects a path segment that would let a `config set`/`unset` reach the prototype chain. */
|
|
180
|
+
function assertSafeKey(key: string, path: string): void {
|
|
181
|
+
if (UNSAFE_KEYS.has(key)) {
|
|
182
|
+
throw new CLIError(`Illegal config path segment "${key}" in "${path}".`, 'INVALID_ARGUMENT_ERROR', { path, key });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
115
186
|
/** Sets the value at a dotted path, creating intermediate objects. */
|
|
116
187
|
export function setConfigPath(config: Record<string, unknown>, path: string, value: unknown): void {
|
|
117
188
|
const keys = path.split('.');
|
|
189
|
+
keys.forEach(key => assertSafeKey(key, path));
|
|
118
190
|
const last = keys.pop();
|
|
119
191
|
if (!last) throw new CLIError('Config path must be non-empty.', 'INVALID_ARGUMENT_ERROR');
|
|
120
192
|
let node = config;
|
|
@@ -128,6 +200,7 @@ export function setConfigPath(config: Record<string, unknown>, path: string, val
|
|
|
128
200
|
/** Deletes the value at a dotted path. No-op if the path does not exist. */
|
|
129
201
|
export function unsetConfigPath(config: Record<string, unknown>, path: string): void {
|
|
130
202
|
const keys = path.split('.');
|
|
203
|
+
keys.forEach(key => assertSafeKey(key, path));
|
|
131
204
|
const last = keys.pop();
|
|
132
205
|
if (!last) return;
|
|
133
206
|
let node: Record<string, unknown> | undefined = config;
|
|
@@ -160,6 +233,9 @@ export type ApiFactory = (network?: NetworkOption, overrides?: ConnectionOverrid
|
|
|
160
233
|
* | `BTCR2_BTC_RPC_PASS` | `--btc-rpc-pass` |
|
|
161
234
|
* | `BTCR2_CAS_GATEWAY` | `--cas-gateway` |
|
|
162
235
|
* | `BTCR2_CAS_RPC_URL` | `--cas-rpc-url` |
|
|
236
|
+
* | `BTCR2_BTC_TIMEOUT` | `--btc-timeout` |
|
|
237
|
+
* | `BTCR2_CAS_TIMEOUT` | `--cas-timeout` |
|
|
238
|
+
* | `BTCR2_FEE_RATE` | `--fee-rate` |
|
|
163
239
|
*/
|
|
164
240
|
export const ENV_VARS = {
|
|
165
241
|
BTC_REST : 'BTCR2_BTC_REST',
|
|
@@ -168,6 +244,9 @@ export const ENV_VARS = {
|
|
|
168
244
|
BTC_RPC_PASS : 'BTCR2_BTC_RPC_PASS',
|
|
169
245
|
CAS_GATEWAY : 'BTCR2_CAS_GATEWAY',
|
|
170
246
|
CAS_RPC_URL : 'BTCR2_CAS_RPC_URL',
|
|
247
|
+
BTC_TIMEOUT : 'BTCR2_BTC_TIMEOUT',
|
|
248
|
+
CAS_TIMEOUT : 'BTCR2_CAS_TIMEOUT',
|
|
249
|
+
FEE_RATE : 'BTCR2_FEE_RATE',
|
|
171
250
|
} as const;
|
|
172
251
|
|
|
173
252
|
/**
|
|
@@ -187,30 +266,68 @@ export function readEnvOverrides(): ConnectionOverrides {
|
|
|
187
266
|
}
|
|
188
267
|
|
|
189
268
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* 3. `~/.config/btcr2/config.json` (fallback)
|
|
269
|
+
* Reads and JSON-parses a config file without applying the schema-version
|
|
270
|
+
* ceiling check. Returns `undefined` only for a genuinely absent file (ENOENT).
|
|
271
|
+
* Any other read failure, and any JSON parse failure, throws a {@link CLIError}
|
|
272
|
+
* that names the file. Used by `config validate`, which reports a newer-than-
|
|
273
|
+
* supported `schemaVersion` as a finding rather than aborting on it.
|
|
196
274
|
*/
|
|
197
|
-
export function
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
275
|
+
export function parseConfigFileRaw(path: string): Record<string, unknown> | undefined {
|
|
276
|
+
let content: string;
|
|
277
|
+
try {
|
|
278
|
+
content = readFileSync(path, 'utf-8');
|
|
279
|
+
} catch (error: unknown) {
|
|
280
|
+
if ((error as { code?: string }).code === 'ENOENT') return undefined;
|
|
281
|
+
throw new CLIError(
|
|
282
|
+
`Failed to read config file at ${path}: ${(error as Error).message}`,
|
|
283
|
+
'CONFIG_READ_ERROR',
|
|
284
|
+
{ path },
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
289
|
+
} catch (error: unknown) {
|
|
290
|
+
throw new CLIError(
|
|
291
|
+
`Config file at ${path} is not valid JSON: ${(error as Error).message}. `
|
|
292
|
+
+ 'Fix the file by hand; the CLI will not overwrite it while it is unparseable.',
|
|
293
|
+
'CONFIG_PARSE_ERROR',
|
|
294
|
+
{ path },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
202
297
|
}
|
|
203
298
|
|
|
204
299
|
/**
|
|
205
|
-
* Reads and parses a config file. Returns `undefined`
|
|
206
|
-
*
|
|
300
|
+
* Reads and parses a config file. Returns `undefined` only when the file is
|
|
301
|
+
* genuinely absent (ENOENT), so callers can safely treat "no file" as "use
|
|
302
|
+
* defaults". Any other read failure, and any JSON parse failure, throws a
|
|
303
|
+
* {@link CLIError} that names the file, rather than silently degrading to the
|
|
304
|
+
* public network defaults. A file written by a newer CLI (higher `schemaVersion`)
|
|
305
|
+
* is also refused.
|
|
207
306
|
*/
|
|
208
307
|
export function readConfigFile(path: string): ConfigFile | undefined {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
308
|
+
const parsed = parseConfigFileRaw(path);
|
|
309
|
+
if (parsed === undefined) return undefined;
|
|
310
|
+
migrateConfigShape(parsed, path);
|
|
311
|
+
return parsed as ConfigFile;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Validates a parsed config's `schemaVersion` against {@link CONFIG_SCHEMA_VERSION}
|
|
316
|
+
* and brings an older shape up to the current version in place. A file written by
|
|
317
|
+
* a newer CLI is refused so today's assumptions are never applied blindly to an
|
|
318
|
+
* unknown shape. An absent version is treated as the earliest and migrated
|
|
319
|
+
* forward. No structural migrations are registered yet (the schema is at version
|
|
320
|
+
* 1); future versions register their transforms here in ascending order.
|
|
321
|
+
*/
|
|
322
|
+
function migrateConfigShape(raw: Record<string, unknown>, path: string): void {
|
|
323
|
+
const version = typeof raw.schemaVersion === 'number' ? raw.schemaVersion : 0;
|
|
324
|
+
if (version > CONFIG_SCHEMA_VERSION) {
|
|
325
|
+
throw new CLIError(
|
|
326
|
+
`Config file at ${path} has schemaVersion ${version}, but this CLI supports up to `
|
|
327
|
+
+ `${CONFIG_SCHEMA_VERSION}. Upgrade the btcr2 CLI to read it.`,
|
|
328
|
+
'CONFIG_SCHEMA_VERSION_ERROR',
|
|
329
|
+
{ path, fileVersion: version, supported: CONFIG_SCHEMA_VERSION },
|
|
330
|
+
);
|
|
214
331
|
}
|
|
215
332
|
}
|
|
216
333
|
|
|
@@ -234,29 +351,142 @@ export function profileToOverrides(
|
|
|
234
351
|
};
|
|
235
352
|
}
|
|
236
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Resolves the active profile name and the network it targets, shared by
|
|
356
|
+
* {@link resolveDefaultNetwork} and {@link resolveConnectionConfig} so the two
|
|
357
|
+
* can never disagree about which profile is active or which network it means.
|
|
358
|
+
*
|
|
359
|
+
* The active profile name is the explicit `--profile` flag, else the config
|
|
360
|
+
* file's `defaults.profile`. The network is the profile's own `network` field
|
|
361
|
+
* when set to a supported value, else the profile name itself when it is a
|
|
362
|
+
* network name (the historical convention). A profile that declares no network
|
|
363
|
+
* and is not named after one yields `network: undefined`.
|
|
364
|
+
*/
|
|
365
|
+
export function resolveActiveProfile(
|
|
366
|
+
file : ConfigFile | undefined,
|
|
367
|
+
overrides?: ConnectionOverrides,
|
|
368
|
+
): { name: string | undefined; network: NetworkOption | undefined } {
|
|
369
|
+
const name = blankToUndef(overrides?.profile) ?? file?.defaults?.profile;
|
|
370
|
+
if (!name) return { name: undefined, network: undefined };
|
|
371
|
+
|
|
372
|
+
const declared = file?.profiles?.[name]?.network;
|
|
373
|
+
const network = (declared && SUPPORTED_NETWORKS.includes(declared))
|
|
374
|
+
? declared
|
|
375
|
+
: (SUPPORTED_NETWORKS.includes(name as NetworkOption) ? name as NetworkOption : undefined);
|
|
376
|
+
return { name, network };
|
|
377
|
+
}
|
|
378
|
+
|
|
237
379
|
/**
|
|
238
380
|
* Resolves the default Bitcoin network for offline identifier creation when no
|
|
239
381
|
* `--network` flag is given. Resolution order: the config file's
|
|
240
|
-
* `defaults.network`, then
|
|
241
|
-
*
|
|
382
|
+
* `defaults.network`, then the active profile's network (its explicit `network`
|
|
383
|
+
* field, else its network-derived name), then `regtest` as the development
|
|
242
384
|
* fallback. Generation itself is offline; this only fixes which network the
|
|
243
385
|
* identifier encodes.
|
|
244
386
|
*/
|
|
245
387
|
export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkOption {
|
|
246
|
-
const configPath = overrides?.config ?? defaultConfigPath();
|
|
388
|
+
const configPath = overrides?.config ?? defaultConfigPath(overrides);
|
|
247
389
|
const file = readConfigFile(configPath);
|
|
248
390
|
|
|
249
391
|
const explicit = file?.defaults?.network;
|
|
250
392
|
if (explicit && SUPPORTED_NETWORKS.includes(explicit)) return explicit;
|
|
251
393
|
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
return profile as NetworkOption;
|
|
255
|
-
}
|
|
394
|
+
const { network } = resolveActiveProfile(file, overrides);
|
|
395
|
+
if (network) return network;
|
|
256
396
|
|
|
257
397
|
return 'regtest';
|
|
258
398
|
}
|
|
259
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Reports a coherence conflict between the network a `create` run is about to
|
|
402
|
+
* encode and the network the active profile declares, so the CLI can warn
|
|
403
|
+
* instead of silently minting an identifier on one network while wiring
|
|
404
|
+
* endpoints for another. Returns `undefined` when the active profile declares
|
|
405
|
+
* no network or agrees with the one being encoded.
|
|
406
|
+
*/
|
|
407
|
+
export function profileNetworkMismatch(
|
|
408
|
+
network : NetworkOption,
|
|
409
|
+
overrides?: ConnectionOverrides,
|
|
410
|
+
): { profile: string; declared: NetworkOption } | undefined {
|
|
411
|
+
// This drives only a warning, so a malformed config must not break an otherwise
|
|
412
|
+
// offline `create`; a genuinely broken config is still surfaced loudly by any
|
|
413
|
+
// command that actually resolves a connection.
|
|
414
|
+
let file: ConfigFile | undefined;
|
|
415
|
+
try {
|
|
416
|
+
file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
|
|
417
|
+
} catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
const { name, network: declared } = resolveActiveProfile(file, overrides);
|
|
421
|
+
if (name && declared && declared !== network) return { profile: name, declared };
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Resolves the effective output format: the `-o/--output` flag, then the
|
|
427
|
+
* `BTCR2_OUTPUT` environment variable, then the config file's `defaults.output`,
|
|
428
|
+
* then `'text'`. A malformed config never blocks output resolution (the command's
|
|
429
|
+
* own read path surfaces it); output format falls back to `'text'` instead.
|
|
430
|
+
*/
|
|
431
|
+
export function resolveOutputFormat(options: { output?: string; config?: string; home?: string }): OutputFormat {
|
|
432
|
+
const candidates: Array<string | undefined> = [
|
|
433
|
+
blankToUndef(options.output),
|
|
434
|
+
process.env.BTCR2_OUTPUT || undefined,
|
|
435
|
+
];
|
|
436
|
+
try {
|
|
437
|
+
candidates.push(readConfigFile(options.config ?? defaultConfigPath(options))?.defaults?.output);
|
|
438
|
+
} catch {
|
|
439
|
+
// Output format is cosmetic; a broken config is reported by the command
|
|
440
|
+
// itself rather than aborting here (which would block a recovery command).
|
|
441
|
+
}
|
|
442
|
+
for (const candidate of candidates) {
|
|
443
|
+
if (candidate === 'json' || candidate === 'text') return candidate;
|
|
444
|
+
}
|
|
445
|
+
return 'text';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The resolved RPC credential unit: url, user, and pass drawn from a single
|
|
450
|
+
* precedence layer, tagged with that layer's provenance. `pass` is kept raw so a
|
|
451
|
+
* secret-ref (`env:`/`file:`) can be resolved by the caller.
|
|
452
|
+
*/
|
|
453
|
+
interface RpcUnit {
|
|
454
|
+
src : Provenance;
|
|
455
|
+
url? : string;
|
|
456
|
+
user? : string;
|
|
457
|
+
pass? : string;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolves the RPC credential unit atomically: the highest-precedence layer that
|
|
462
|
+
* supplies a url, else the highest that supplies a username or password. url,
|
|
463
|
+
* user, and pass therefore always come from one layer, so a host from one layer
|
|
464
|
+
* is never handed another layer's credentials (ADR 074). When no layer supplies a
|
|
465
|
+
* url, the credentials still resolve (so they reach the SDK's per-network default
|
|
466
|
+
* host, e.g. regtest's, without the url being restated). Returns `undefined` when
|
|
467
|
+
* no layer supplies a url or a credential.
|
|
468
|
+
*/
|
|
469
|
+
function resolveRpcUnit(
|
|
470
|
+
overrides? : ConnectionOverrides,
|
|
471
|
+
env? : ConnectionOverrides,
|
|
472
|
+
fileOverrides?: ConnectionOverrides,
|
|
473
|
+
): RpcUnit | undefined {
|
|
474
|
+
const layers: Array<{ src: Provenance; url?: string; user?: string; pass?: string }> = [
|
|
475
|
+
{ src: 'flag', url: overrides?.btcRpcUrl, user: overrides?.btcRpcUser, pass: overrides?.btcRpcPass },
|
|
476
|
+
{ src: 'env', url: env?.btcRpcUrl, user: env?.btcRpcUser, pass: env?.btcRpcPass },
|
|
477
|
+
{ src: 'file', url: fileOverrides?.btcRpcUrl, user: fileOverrides?.btcRpcUser, pass: fileOverrides?.btcRpcPass },
|
|
478
|
+
];
|
|
479
|
+
const withUrl = layers.find(l => blankToUndef(l.url) !== undefined);
|
|
480
|
+
if (withUrl) {
|
|
481
|
+
return { src: withUrl.src, url: blankToUndef(withUrl.url), user: blankToUndef(withUrl.user), pass: blankToUndef(withUrl.pass) };
|
|
482
|
+
}
|
|
483
|
+
const withCreds = layers.find(l => blankToUndef(l.user) !== undefined || blankToUndef(l.pass) !== undefined);
|
|
484
|
+
if (withCreds) {
|
|
485
|
+
return { src: withCreds.src, url: undefined, user: blankToUndef(withCreds.user), pass: blankToUndef(withCreds.pass) };
|
|
486
|
+
}
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
|
|
260
490
|
/**
|
|
261
491
|
* Resolves the Bitcoin and CAS connection config for a network by merging,
|
|
262
492
|
* in precedence order, CLI flags, environment variables, and the config-file
|
|
@@ -268,56 +498,435 @@ export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkO
|
|
|
268
498
|
* When no `--profile` is given, the network name is used as the profile key
|
|
269
499
|
* (e.g. a regtest DID auto-selects the `"regtest"` profile).
|
|
270
500
|
*/
|
|
271
|
-
function resolveConnectionConfig(
|
|
501
|
+
export function resolveConnectionConfig(
|
|
272
502
|
network? : NetworkOption,
|
|
273
503
|
overrides?: ConnectionOverrides,
|
|
274
504
|
): { btc?: BitcoinApiConfig; cas?: CasConfig } {
|
|
275
505
|
if (!network) return {};
|
|
276
506
|
|
|
277
|
-
// Layer 1: Config file profile (lowest precedence of the three override layers)
|
|
278
|
-
|
|
507
|
+
// Layer 1: Config file profile (lowest precedence of the three override layers).
|
|
508
|
+
// The active-profile name is resolved through the same shared helper as
|
|
509
|
+
// resolveDefaultNetwork so the two cannot disagree about which profile is live.
|
|
510
|
+
const configPath = overrides?.config ?? defaultConfigPath(overrides);
|
|
279
511
|
const file = readConfigFile(configPath);
|
|
280
|
-
const
|
|
512
|
+
const { name: activeProfile } = resolveActiveProfile(file, overrides);
|
|
513
|
+
const profileName = activeProfile ?? network;
|
|
281
514
|
const fileOverrides = file ? profileToOverrides(file, profileName) : {};
|
|
282
515
|
|
|
283
516
|
// Layer 2: Environment variables
|
|
284
517
|
const env = readEnvOverrides();
|
|
285
518
|
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
519
|
+
// Blank-aware precedence merge: CLI flag -> env var -> config file. A blank at
|
|
520
|
+
// any layer defers to the next instead of masking it (mirrors the env layer's
|
|
521
|
+
// `|| undefined`), so an empty flag or profile field no longer silently reverts
|
|
522
|
+
// resolution to the SDK network default.
|
|
523
|
+
const pick = (flag?: string, envVal?: string, fileVal?: string): string | undefined =>
|
|
524
|
+
blankToUndef(flag) ?? blankToUndef(envVal) ?? blankToUndef(fileVal);
|
|
525
|
+
|
|
526
|
+
const profileBtc = file?.profiles?.[profileName]?.btc;
|
|
527
|
+
const profileCas = file?.profiles?.[profileName]?.cas;
|
|
295
528
|
|
|
296
529
|
const btc: BitcoinApiConfig = { network };
|
|
297
530
|
|
|
298
|
-
|
|
299
|
-
|
|
531
|
+
// REST host and headers. Headers apply even without a host override, layering
|
|
532
|
+
// onto the per-network default host inside the api's config merge, so an
|
|
533
|
+
// authenticated Esplora/mempool endpoint can be reached with the default host.
|
|
534
|
+
const btcRest = pick(overrides?.btcRest, env.btcRest, fileOverrides.btcRest);
|
|
535
|
+
const restHeaders = mergeHeaders(profileBtc?.headers, parseHeaderList(overrides?.btcRestHeader, '--btc-rest-header'));
|
|
536
|
+
if (btcRest || restHeaders) {
|
|
537
|
+
btc.rest = { ...(btcRest ? { host: btcRest } : {}), ...(restHeaders ? { headers: restHeaders } : {}) };
|
|
300
538
|
}
|
|
301
539
|
|
|
302
|
-
|
|
540
|
+
// Resolve the RPC endpoint as one atomic credential unit (url + user + pass from
|
|
541
|
+
// one layer), plus the orthogonal wallet and header augmentations.
|
|
542
|
+
const rpcUnit = resolveRpcUnit(overrides, env, fileOverrides);
|
|
543
|
+
const rpcWallet = pick(overrides?.btcRpcWallet, undefined, profileBtc?.wallet);
|
|
544
|
+
const rpcHeaders = mergeHeaders(profileBtc?.rpcHeaders, parseHeaderList(overrides?.btcRpcHeader, '--btc-rpc-header'));
|
|
545
|
+
|
|
546
|
+
// Build an RPC config only when a host will actually exist to talk to: either
|
|
547
|
+
// the unit supplies a url, or the network has a default RPC host (regtest).
|
|
548
|
+
// Wallet/header (or credential) knobs alone with no host would otherwise point
|
|
549
|
+
// a phantom client at the default 127.0.0.1:8332 and, on public networks, flip
|
|
550
|
+
// the connection to "has RPC" spuriously (and could leak a header credential
|
|
551
|
+
// meant for a remote proxy). The pass-file fallback is read lazily inside this
|
|
552
|
+
// block, so a set-but-unreadable BTCR2_BTC_RPC_PASS_FILE never aborts a command
|
|
553
|
+
// that uses no RPC at all.
|
|
554
|
+
const networkHasDefaultRpc = DEFAULT_BITCOIN_NETWORK_CONFIG[network].rpc !== undefined;
|
|
555
|
+
const wantsRpc = rpcUnit !== undefined || rpcWallet !== undefined || rpcHeaders !== undefined;
|
|
556
|
+
const hasRpcHost = rpcUnit?.url !== undefined || networkHasDefaultRpc;
|
|
557
|
+
if (wantsRpc && hasRpcHost) {
|
|
558
|
+
const password = resolveSecretRef(rpcUnit?.pass) ?? readRpcPassFile();
|
|
303
559
|
btc.rpc = {
|
|
304
|
-
host
|
|
305
|
-
username :
|
|
306
|
-
password :
|
|
560
|
+
...(rpcUnit?.url !== undefined ? { host: rpcUnit.url } : {}),
|
|
561
|
+
...(rpcUnit?.user !== undefined ? { username: rpcUnit.user } : {}),
|
|
562
|
+
...(password !== undefined ? { password } : {}),
|
|
563
|
+
...(rpcWallet ? { wallet: rpcWallet } : {}),
|
|
564
|
+
...(rpcHeaders ? { headers: rpcHeaders } : {}),
|
|
307
565
|
};
|
|
308
566
|
}
|
|
309
567
|
|
|
568
|
+
// Bitcoin request timeout. No default: honored only when explicitly set, so
|
|
569
|
+
// callers that rely on unbounded waits are unaffected (ADR 076).
|
|
570
|
+
const btcTimeout = resolveTimeout(overrides?.btcTimeout, process.env[ENV_VARS.BTC_TIMEOUT], profileBtc?.timeoutMs, '--btc-timeout', 1);
|
|
571
|
+
if (btcTimeout !== undefined) btc.timeoutMs = btcTimeout;
|
|
572
|
+
|
|
310
573
|
// A configured RPC endpoint is writable and takes precedence over the
|
|
311
574
|
// read-only gateway (matching the api's CasConfig priority: rpcUrl > gateway).
|
|
312
575
|
// Both may be set; the api selects one executor from them.
|
|
576
|
+
const casGateway = pick(overrides?.casGateway, env.casGateway, fileOverrides.casGateway);
|
|
577
|
+
const casRpcUrl = pick(overrides?.casRpcUrl, env.casRpcUrl, fileOverrides.casRpcUrl);
|
|
578
|
+
const casTimeout = resolveTimeout(overrides?.casTimeout, process.env[ENV_VARS.CAS_TIMEOUT], profileCas?.timeoutMs, '--cas-timeout');
|
|
313
579
|
const cas: CasConfig = {};
|
|
314
|
-
if (
|
|
315
|
-
if (
|
|
316
|
-
|
|
580
|
+
if (casGateway) cas.gateway = casGateway;
|
|
581
|
+
if (casRpcUrl) cas.rpcUrl = casRpcUrl;
|
|
582
|
+
if (casTimeout !== undefined) {
|
|
583
|
+
cas.timeoutMs = casTimeout;
|
|
584
|
+
// A timeout needs an endpoint to attach to. When none is configured, fall
|
|
585
|
+
// back to the same default gateway the api would otherwise apply, so the
|
|
586
|
+
// timeout is honored rather than dropped (the api only defaults the gateway
|
|
587
|
+
// when the whole cas config is absent).
|
|
588
|
+
if (!casGateway && !casRpcUrl) cas.gateway = DEFAULT_CAS_GATEWAY;
|
|
589
|
+
}
|
|
590
|
+
const hasCas = casGateway || casRpcUrl || casTimeout !== undefined;
|
|
317
591
|
|
|
318
592
|
return { btc, ...(hasCas && { cas }) };
|
|
319
593
|
}
|
|
320
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Resolves a millisecond timeout from a flag string, an env string, then a
|
|
597
|
+
* config-file number, in precedence order. Returns `undefined` when none is set
|
|
598
|
+
* (preserving unbounded behavior). Throws a {@link CLIError} for a value below
|
|
599
|
+
* `min` or not a finite number. `min` is `0` for CAS (where `0` disables the
|
|
600
|
+
* timeout) and `1` for the Bitcoin timeout (where `0` would abort every request
|
|
601
|
+
* immediately, which is never intended).
|
|
602
|
+
*/
|
|
603
|
+
function resolveTimeout(flag?: string, envVal?: string, fileVal?: number, flagName = 'timeout', min = 0): number | undefined {
|
|
604
|
+
const raw = blankToUndef(flag) ?? blankToUndef(envVal) ?? (typeof fileVal === 'number' ? String(fileVal) : undefined);
|
|
605
|
+
if (raw === undefined) return undefined;
|
|
606
|
+
const ms = Number(raw);
|
|
607
|
+
if (!Number.isFinite(ms) || ms < min) {
|
|
608
|
+
throw new CLIError(
|
|
609
|
+
`Invalid ${flagName} value "${raw}": expected a number of milliseconds >= ${min}.`,
|
|
610
|
+
'INVALID_ARGUMENT_ERROR',
|
|
611
|
+
{ value: raw },
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return ms;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Parses repeatable `Key: Value` header flag values into a header map. Returns
|
|
619
|
+
* `undefined` for an empty list. Throws a {@link CLIError} for an entry missing a
|
|
620
|
+
* colon or with an empty key.
|
|
621
|
+
*/
|
|
622
|
+
export function parseHeaderList(list?: string[], flagName = '--header'): Record<string, string> | undefined {
|
|
623
|
+
if (!list || list.length === 0) return undefined;
|
|
624
|
+
const headers: Record<string, string> = {};
|
|
625
|
+
for (const entry of list) {
|
|
626
|
+
const idx = entry.indexOf(':');
|
|
627
|
+
const key = idx === -1 ? '' : entry.slice(0, idx).trim();
|
|
628
|
+
if (idx === -1 || key === '') {
|
|
629
|
+
throw new CLIError(
|
|
630
|
+
`Invalid ${flagName} "${entry}": expected "Key: Value".`,
|
|
631
|
+
'INVALID_ARGUMENT_ERROR',
|
|
632
|
+
{ header: entry },
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
headers[key] = entry.slice(idx + 1).trim();
|
|
636
|
+
}
|
|
637
|
+
return headers;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Merges a base header map (from the config-file profile) with an override map
|
|
642
|
+
* (from flags), with the override winning per key. Returns `undefined` when both
|
|
643
|
+
* are absent so callers can skip attaching an empty header map.
|
|
644
|
+
*/
|
|
645
|
+
function mergeHeaders(
|
|
646
|
+
base? : Record<string, string>,
|
|
647
|
+
override? : Record<string, string>,
|
|
648
|
+
): Record<string, string> | undefined {
|
|
649
|
+
if (!base && !override) return undefined;
|
|
650
|
+
return { ...base, ...override };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Environment variable naming a file whose contents are the Bitcoin Core RPC password. */
|
|
654
|
+
export const ENV_RPC_PASS_FILE = 'BTCR2_BTC_RPC_PASS_FILE';
|
|
655
|
+
|
|
656
|
+
/** Removes at most one trailing newline, matching the keystore-passphrase normalization. */
|
|
657
|
+
function trimTrailingNewline(value: string): string {
|
|
658
|
+
return value.replace(/\r?\n$/, '');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Reads a secret file, throwing a {@link CLIError} (not a raw Node error) that names the path and source. */
|
|
662
|
+
function readSecretFile(path: string, source: string): string {
|
|
663
|
+
try {
|
|
664
|
+
return trimTrailingNewline(readFileSync(path, 'utf-8'));
|
|
665
|
+
} catch (error: unknown) {
|
|
666
|
+
throw new CLIError(
|
|
667
|
+
`Could not read the RPC password ${source} at ${path}: ${(error as Error).message}`,
|
|
668
|
+
'CONFIG_READ_ERROR',
|
|
669
|
+
{ path },
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Resolves an RPC-password secret reference to its literal value: `env:<VAR>`
|
|
676
|
+
* reads the named environment variable, `file:<path>` reads the file, and any
|
|
677
|
+
* other value is returned as-is. A trailing newline is trimmed from file/env
|
|
678
|
+
* sources so a secret written by `echo` matches an inline value (ADR 077).
|
|
679
|
+
*/
|
|
680
|
+
export function resolveSecretRef(value?: string): string | undefined {
|
|
681
|
+
if (value === undefined) return undefined;
|
|
682
|
+
if (value.startsWith('env:')) {
|
|
683
|
+
const fromEnv = process.env[value.slice(4)];
|
|
684
|
+
return fromEnv === undefined ? undefined : trimTrailingNewline(fromEnv);
|
|
685
|
+
}
|
|
686
|
+
if (value.startsWith('file:')) {
|
|
687
|
+
return readSecretFile(value.slice(5), 'file reference');
|
|
688
|
+
}
|
|
689
|
+
return value;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Reads the RPC password from an {@link ENV_RPC_PASS_FILE}-named file, if set. */
|
|
693
|
+
function readRpcPassFile(): string | undefined {
|
|
694
|
+
const path = process.env[ENV_RPC_PASS_FILE];
|
|
695
|
+
if (!path) return undefined;
|
|
696
|
+
return readSecretFile(path, `file named by ${ENV_RPC_PASS_FILE}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Resolves the beacon {@link BroadcastOptions} for an update/deactivate from the
|
|
701
|
+
* fee-rate and change-address knobs, following the CLI precedence chain.
|
|
702
|
+
*
|
|
703
|
+
* - Fee rate: `--fee-rate` flag, then `BTCR2_FEE_RATE`, then profile
|
|
704
|
+
* `btc.feeRate`. A positive sats/vByte value wrapped in a `StaticFeeEstimator`.
|
|
705
|
+
* - Change address: `--change-address` flag, then profile `btc.changeAddress`
|
|
706
|
+
* (no env, since a change address is DID/network-specific). Validated against
|
|
707
|
+
* the DID network by the beacon at broadcast time.
|
|
708
|
+
*
|
|
709
|
+
* Returns `undefined` when neither is set, so the SDK defaults (5 sat/vB, change
|
|
710
|
+
* back to the beacon address) still apply.
|
|
711
|
+
*/
|
|
712
|
+
export function resolveBroadcastOptions(
|
|
713
|
+
network : NetworkOption,
|
|
714
|
+
overrides: ConnectionOverrides | undefined,
|
|
715
|
+
flags : { feeRate?: string; changeAddress?: string },
|
|
716
|
+
): BroadcastOptions | undefined {
|
|
717
|
+
const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
|
|
718
|
+
const { name: activeProfile } = resolveActiveProfile(file, overrides);
|
|
719
|
+
const profileBtc = file?.profiles?.[activeProfile ?? network]?.btc;
|
|
720
|
+
|
|
721
|
+
const options: BroadcastOptions = {};
|
|
722
|
+
|
|
723
|
+
const feeRateRaw = blankToUndef(flags.feeRate)
|
|
724
|
+
?? blankToUndef(process.env[ENV_VARS.FEE_RATE])
|
|
725
|
+
?? (typeof profileBtc?.feeRate === 'number' ? String(profileBtc.feeRate) : undefined);
|
|
726
|
+
if (feeRateRaw !== undefined) options.feeEstimator = new StaticFeeEstimator(parseFeeRate(feeRateRaw));
|
|
727
|
+
|
|
728
|
+
const changeAddress = blankToUndef(flags.changeAddress) ?? blankToUndef(profileBtc?.changeAddress);
|
|
729
|
+
if (changeAddress) options.changeAddress = changeAddress;
|
|
730
|
+
|
|
731
|
+
return options.feeEstimator || options.changeAddress ? options : undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Parses a positive sats/vByte fee rate, throwing a {@link CLIError} otherwise. */
|
|
735
|
+
function parseFeeRate(raw: string): number {
|
|
736
|
+
const rate = Number(raw);
|
|
737
|
+
if (!Number.isFinite(rate) || rate <= 0) {
|
|
738
|
+
throw new CLIError(
|
|
739
|
+
`Invalid --fee-rate "${raw}": expected a positive number of sats per vByte.`,
|
|
740
|
+
'INVALID_ARGUMENT_ERROR',
|
|
741
|
+
{ value: raw },
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
return rate;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Which precedence layer a resolved value came from. */
|
|
748
|
+
export type Provenance = 'flag' | 'env' | 'file' | 'default';
|
|
749
|
+
|
|
750
|
+
/** A resolved value paired with the layer it came from. */
|
|
751
|
+
export interface EffectiveEntry {
|
|
752
|
+
value : string | number | undefined;
|
|
753
|
+
source : Provenance;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The resolved connection config with per-value provenance, the shape behind
|
|
758
|
+
* `config effective`. Values are read through the real resolver (the constructed
|
|
759
|
+
* api and {@link resolveConnectionConfig}) so they cannot drift from what a live
|
|
760
|
+
* command would use; the `source` tag names the layer the merge selected.
|
|
761
|
+
*/
|
|
762
|
+
export interface EffectiveConfig {
|
|
763
|
+
network : NetworkOption;
|
|
764
|
+
profile : string | undefined;
|
|
765
|
+
btc : {
|
|
766
|
+
rest : EffectiveEntry;
|
|
767
|
+
rpcUrl : EffectiveEntry;
|
|
768
|
+
rpcUser : EffectiveEntry;
|
|
769
|
+
rpcPass : EffectiveEntry;
|
|
770
|
+
rpcWallet : EffectiveEntry;
|
|
771
|
+
timeoutMs : EffectiveEntry;
|
|
772
|
+
};
|
|
773
|
+
cas : {
|
|
774
|
+
gateway : EffectiveEntry;
|
|
775
|
+
rpcUrl : EffectiveEntry;
|
|
776
|
+
timeoutMs : EffectiveEntry;
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Resolves the effective connection config with provenance for `config effective`.
|
|
782
|
+
* The btc values are read back from the constructed api (so SDK network defaults
|
|
783
|
+
* are reflected), the cas and timeout values from {@link resolveConnectionConfig},
|
|
784
|
+
* and each `source` is derived by the same precedence order the merge uses.
|
|
785
|
+
*/
|
|
786
|
+
export function resolveEffectiveConfig(network: NetworkOption, overrides?: ConnectionOverrides): EffectiveConfig {
|
|
787
|
+
const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
|
|
788
|
+
const { name: activeProfile } = resolveActiveProfile(file, overrides);
|
|
789
|
+
const profileName = activeProfile ?? network;
|
|
790
|
+
const fileOv = file ? profileToOverrides(file, profileName) : {};
|
|
791
|
+
const profileBtc = file?.profiles?.[profileName]?.btc;
|
|
792
|
+
const profileCas = file?.profiles?.[profileName]?.cas;
|
|
793
|
+
const env = readEnvOverrides();
|
|
794
|
+
|
|
795
|
+
const api = defaultApiFactory(network, overrides);
|
|
796
|
+
const restCfg = api.btc.connection.rest.config;
|
|
797
|
+
const rpcCfg = api.btc.connection.rpc?.config;
|
|
798
|
+
const conn = resolveConnectionConfig(network, overrides);
|
|
799
|
+
|
|
800
|
+
const src = (flag?: string, envVal?: string, fileVal?: unknown): Provenance =>
|
|
801
|
+
blankToUndef(flag) !== undefined ? 'flag'
|
|
802
|
+
: blankToUndef(envVal) !== undefined ? 'env'
|
|
803
|
+
: (fileVal !== undefined && fileVal !== null && String(fileVal).trim() !== '') ? 'file'
|
|
804
|
+
: 'default';
|
|
805
|
+
|
|
806
|
+
// CAS has no client to read back from; derive its resolved endpoint from the
|
|
807
|
+
// connection resolver, defaulting the gateway the way the api would.
|
|
808
|
+
const casGatewayVal = conn.cas?.gateway ?? (conn.cas?.rpcUrl ? undefined : DEFAULT_CAS_GATEWAY);
|
|
809
|
+
|
|
810
|
+
// RPC url/user/pass provenance follows the atomic-credential unit, so a value's
|
|
811
|
+
// reported source is the layer the merge actually bound it to (never an
|
|
812
|
+
// independent per-field guess that could disagree with the resolver). A password
|
|
813
|
+
// taken from BTCR2_BTC_RPC_PASS_FILE when the unit supplied none is env-sourced.
|
|
814
|
+
const rpcUnit = resolveRpcUnit(overrides, env, fileOv);
|
|
815
|
+
const rpcSrc = rpcUnit?.src ?? 'default';
|
|
816
|
+
const passFromFile = rpcUnit?.pass === undefined
|
|
817
|
+
&& process.env[ENV_RPC_PASS_FILE] !== undefined
|
|
818
|
+
&& rpcCfg?.password !== undefined;
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
network,
|
|
822
|
+
profile : activeProfile,
|
|
823
|
+
btc : {
|
|
824
|
+
rest : { value: restCfg.host, source: src(overrides?.btcRest, env.btcRest, fileOv.btcRest) },
|
|
825
|
+
rpcUrl : { value: rpcCfg?.host, source: rpcUnit?.url !== undefined ? rpcSrc : 'default' },
|
|
826
|
+
rpcUser : { value: rpcCfg?.username, source: rpcUnit?.user !== undefined ? rpcSrc : 'default' },
|
|
827
|
+
rpcPass : { value: rpcCfg?.password, source: rpcUnit?.pass !== undefined ? rpcSrc : (passFromFile ? 'env' : 'default') },
|
|
828
|
+
rpcWallet : { value: rpcCfg?.wallet, source: src(overrides?.btcRpcWallet, undefined, profileBtc?.wallet) },
|
|
829
|
+
timeoutMs : { value: conn.btc?.timeoutMs, source: src(overrides?.btcTimeout, process.env[ENV_VARS.BTC_TIMEOUT], profileBtc?.timeoutMs) },
|
|
830
|
+
},
|
|
831
|
+
cas : {
|
|
832
|
+
gateway : { value: casGatewayVal, source: src(overrides?.casGateway, env.casGateway, fileOv.casGateway) },
|
|
833
|
+
rpcUrl : { value: conn.cas?.rpcUrl, source: src(overrides?.casRpcUrl, env.casRpcUrl, fileOv.casRpcUrl) },
|
|
834
|
+
timeoutMs : { value: conn.cas?.timeoutMs, source: src(overrides?.casTimeout, process.env[ENV_VARS.CAS_TIMEOUT], profileCas?.timeoutMs) },
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** One endpoint reachability check produced by `config doctor`. */
|
|
840
|
+
export interface DoctorCheck {
|
|
841
|
+
endpoint : 'btc-rest' | 'btc-rpc' | 'cas';
|
|
842
|
+
target : string;
|
|
843
|
+
ok : boolean;
|
|
844
|
+
detail? : string;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Result of `config doctor`: per-endpoint reachability and any coherence warning. */
|
|
848
|
+
export interface DoctorReport {
|
|
849
|
+
checks : DoctorCheck[];
|
|
850
|
+
coherence? : { profile: string; declared: NetworkOption; encoding: NetworkOption };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Default per-probe timeout (ms) for `config doctor`. */
|
|
854
|
+
const DOCTOR_PROBE_TIMEOUT_MS = 5000;
|
|
855
|
+
|
|
856
|
+
/** Fetches a URL with a bounded timeout, reporting reachability rather than throwing. */
|
|
857
|
+
async function probeEndpoint(
|
|
858
|
+
endpoint : DoctorCheck['endpoint'],
|
|
859
|
+
target : string,
|
|
860
|
+
url : string,
|
|
861
|
+
opts? : { method?: 'GET' | 'POST'; headers?: Record<string, string> },
|
|
862
|
+
): Promise<DoctorCheck> {
|
|
863
|
+
try {
|
|
864
|
+
const res = await fetch(url, {
|
|
865
|
+
method : opts?.method ?? 'GET',
|
|
866
|
+
headers : opts?.headers,
|
|
867
|
+
signal : AbortSignal.timeout(DOCTOR_PROBE_TIMEOUT_MS),
|
|
868
|
+
});
|
|
869
|
+
return res.ok
|
|
870
|
+
? { endpoint, target, ok: true }
|
|
871
|
+
: { endpoint, target, ok: false, detail: `HTTP ${res.status}` };
|
|
872
|
+
} catch (error) {
|
|
873
|
+
return { endpoint, target, ok: false, detail: (error as Error).message };
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Races a promise against a timeout so a stalled RPC call cannot hang `doctor`. */
|
|
878
|
+
function withProbeTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
879
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
880
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
881
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
882
|
+
});
|
|
883
|
+
return Promise.race([ promise, timeout ]).finally(() => clearTimeout(timer));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Probes reachability of the resolved endpoints for `config doctor`: a
|
|
888
|
+
* lightweight REST call against btc-rest, a `getblockchaininfo` against btc-rpc
|
|
889
|
+
* when configured, and a reachability check against the resolved CAS. Also
|
|
890
|
+
* surfaces the profile/network coherence warning. Reads and touches the network;
|
|
891
|
+
* never writes.
|
|
892
|
+
*/
|
|
893
|
+
export async function runDoctor(network: NetworkOption, overrides?: ConnectionOverrides): Promise<DoctorReport> {
|
|
894
|
+
const api = defaultApiFactory(network, overrides);
|
|
895
|
+
const checks: DoctorCheck[] = [];
|
|
896
|
+
|
|
897
|
+
const restHost = api.btc.connection.rest.config.host.replace(/\/+$/, '');
|
|
898
|
+
checks.push(await probeEndpoint('btc-rest', restHost, `${restHost}/blocks/tip/height`, { headers: api.btc.connection.rest.config.headers }));
|
|
899
|
+
|
|
900
|
+
const rpc = api.btc.connection.rpc;
|
|
901
|
+
if (rpc) {
|
|
902
|
+
const target = rpc.config.host ?? '(default rpc)';
|
|
903
|
+
try {
|
|
904
|
+
await withProbeTimeout(rpc.getBlockchainInfo(), DOCTOR_PROBE_TIMEOUT_MS);
|
|
905
|
+
checks.push({ endpoint: 'btc-rpc', target, ok: true });
|
|
906
|
+
} catch (error) {
|
|
907
|
+
checks.push({ endpoint: 'btc-rpc', target, ok: false, detail: (error as Error).message });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// A writable IPFS RPC (Kubo) answers only POST, so a bare GET would falsely
|
|
912
|
+
// report a healthy node as down; probe its version endpoint with POST. A
|
|
913
|
+
// read-only gateway answers a plain GET on its base URL.
|
|
914
|
+
const conn = resolveConnectionConfig(network, overrides);
|
|
915
|
+
if (conn.cas?.rpcUrl) {
|
|
916
|
+
const base = conn.cas.rpcUrl.replace(/\/+$/, '');
|
|
917
|
+
checks.push(await probeEndpoint('cas', conn.cas.rpcUrl, `${base}/api/v0/version`, { method: 'POST' }));
|
|
918
|
+
} else {
|
|
919
|
+
const gateway = (conn.cas?.gateway ?? DEFAULT_CAS_GATEWAY).replace(/\/+$/, '');
|
|
920
|
+
checks.push(await probeEndpoint('cas', gateway, gateway));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const mismatch = profileNetworkMismatch(network, overrides);
|
|
924
|
+
return {
|
|
925
|
+
checks,
|
|
926
|
+
...(mismatch ? { coherence: { profile: mismatch.profile, declared: mismatch.declared, encoding: network } } : {}),
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
321
930
|
/**
|
|
322
931
|
* Default {@link ApiFactory} backed by network defaults from
|
|
323
932
|
* `@did-btcr2/bitcoin` (mempool.space for public networks, localhost for
|
|
@@ -340,11 +949,96 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
|
|
|
340
949
|
*/
|
|
341
950
|
function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
|
|
342
951
|
return new FileBackedKeyManager({
|
|
343
|
-
path : overrides
|
|
344
|
-
|
|
952
|
+
path : resolveKeystorePath(overrides),
|
|
953
|
+
// The store decides when to confirm: it passes `confirm: true` only while
|
|
954
|
+
// establishing a fresh keystore's passphrase, so a first-key typo is caught
|
|
955
|
+
// by a second entry (ADR 080). confirm is a no-op for env/file sources.
|
|
956
|
+
getPassphrase : (opts) => acquirePassphrase({ passphraseFile: overrides?.passphraseFile, confirm: opts?.confirm }),
|
|
345
957
|
});
|
|
346
958
|
}
|
|
347
959
|
|
|
960
|
+
/**
|
|
961
|
+
* The protection mode of the resolved keystore, read without decrypting or
|
|
962
|
+
* prompting: `encrypted`, `dev` (plaintext), or `absent`. Used by `keystore
|
|
963
|
+
* status`, `config path`, and the mainnet guard.
|
|
964
|
+
*/
|
|
965
|
+
export function resolveKeystoreProtection(overrides?: ConnectionOverrides): KeystoreProtectionLabel {
|
|
966
|
+
return keystoreProtection(resolveKeystorePath(overrides));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Hard-refuses using an unencrypted dev keystore for a mainnet operation (ADR
|
|
971
|
+
* 080). A plaintext key must never sign or seal a `bitcoin` did:btcr2; the check
|
|
972
|
+
* reads only the keystore's protection header, so it never decrypts or prompts.
|
|
973
|
+
* A no-op for every other network and for encrypted/absent keystores.
|
|
974
|
+
*/
|
|
975
|
+
export function assertKeystoreAllowedForNetwork(network: NetworkOption, overrides?: ConnectionOverrides): void {
|
|
976
|
+
if (network !== 'bitcoin') return;
|
|
977
|
+
if (resolveKeystoreProtection(overrides) !== 'dev') return;
|
|
978
|
+
const path = resolveKeystorePath(overrides);
|
|
979
|
+
throw new CLIError(
|
|
980
|
+
`Refusing a mainnet (bitcoin) operation with the unencrypted dev keystore at ${path}. `
|
|
981
|
+
+ 'Dev keystores hold plaintext keys and are for testnet/regtest throwaway material only. '
|
|
982
|
+
+ 'Establish an encrypted keystore (btcr2 keystore init) for mainnet keys.',
|
|
983
|
+
'DEV_KEYSTORE_MAINNET_ERROR',
|
|
984
|
+
{ path, network },
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Resolves the keystore file path: the `--keystore` flag, else the active
|
|
990
|
+
* profile's `identity.keystore`, else the default `<home>/keystore.json` (ADR
|
|
991
|
+
* 079). The flag always wins over the profile default and never reads the config.
|
|
992
|
+
*
|
|
993
|
+
* A malformed config aborts loudly by default so a keystore-mutating command
|
|
994
|
+
* never silently reads or writes the wrong store. Pass `lenient: true` only for
|
|
995
|
+
* diagnostic/recovery commands (`config path`, `keystore status`) that must still
|
|
996
|
+
* report a path instead of crashing on the very config you ran them to fix; those
|
|
997
|
+
* fall back to the home default when the profile identity cannot be read.
|
|
998
|
+
*/
|
|
999
|
+
export function resolveKeystorePath(overrides?: ConnectionOverrides, options?: { lenient?: boolean }): string {
|
|
1000
|
+
// The flag wins outright and short-circuits before any config read. A blank
|
|
1001
|
+
// flag defers to the profile, and a blank profile `identity.keystore` defers to
|
|
1002
|
+
// the default, so neither resolves the keystore to an empty path.
|
|
1003
|
+
const fromFlag = blankToUndef(overrides?.keystore);
|
|
1004
|
+
if (fromFlag) return fromFlag;
|
|
1005
|
+
let identity: { keystore?: string; default?: string } | undefined;
|
|
1006
|
+
try {
|
|
1007
|
+
identity = activeProfileIdentity(overrides);
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
if (!options?.lenient) throw error;
|
|
1010
|
+
}
|
|
1011
|
+
return blankToUndef(identity?.keystore) ?? defaultKeystorePath(overrides);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Reads the active profile's `identity` block (keystore + default signing key),
|
|
1016
|
+
* or `undefined` when no profile is active. A profile is active only when
|
|
1017
|
+
* selected by `--profile` or the config's `defaults.profile`.
|
|
1018
|
+
*/
|
|
1019
|
+
function activeProfileIdentity(overrides?: ConnectionOverrides): { keystore?: string; default?: string } | undefined {
|
|
1020
|
+
// Intentionally propagates a malformed-config error rather than swallowing it.
|
|
1021
|
+
// This feeds resolveKeystorePath for keystore-mutating commands (key generate/
|
|
1022
|
+
// import, keystore init/change-passphrase) and the mainnet dev-keystore guard,
|
|
1023
|
+
// so a broken config must abort loudly instead of silently resolving to the
|
|
1024
|
+
// default keystore and stranding key material there. Diagnostic-only commands
|
|
1025
|
+
// opt into a graceful fallback via resolveKeystorePath's `lenient` option; do
|
|
1026
|
+
// not add a try/catch here (it would re-hide the keystore misdirection).
|
|
1027
|
+
const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
|
|
1028
|
+
const { name } = resolveActiveProfile(file, overrides);
|
|
1029
|
+
return name ? file?.profiles?.[name]?.identity : undefined;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Resolves the signing-key reference for update/deactivate: the `--signing-key`
|
|
1034
|
+
* flag, else the active profile's `identity.default`, else `undefined` (letting
|
|
1035
|
+
* the KMS fall back to its active key). The flag always wins over the profile
|
|
1036
|
+
* default, consistent with the flag -> profile precedence used elsewhere.
|
|
1037
|
+
*/
|
|
1038
|
+
export function resolveSigningKeyRef(overrides?: ConnectionOverrides): string | undefined {
|
|
1039
|
+
return blankToUndef(overrides?.signingKey) ?? blankToUndef(activeProfileIdentity(overrides)?.default);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
348
1042
|
/**
|
|
349
1043
|
* Keystore-aware {@link ApiFactory} for commands that need a signing identity
|
|
350
1044
|
* (key management, update, deactivate). Identical to {@link defaultApiFactory}
|