bsv-pay-cli 0.2.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 (96) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +435 -0
  4. package/dist/address.d.ts +6 -0
  5. package/dist/address.js +35 -0
  6. package/dist/chain/provider.d.ts +35 -0
  7. package/dist/chain/provider.js +1 -0
  8. package/dist/chain/whatsonchain.d.ts +23 -0
  9. package/dist/chain/whatsonchain.js +98 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +169 -0
  12. package/dist/commands/approvals.d.ts +19 -0
  13. package/dist/commands/approvals.js +112 -0
  14. package/dist/commands/balance.d.ts +3 -0
  15. package/dist/commands/balance.js +28 -0
  16. package/dist/commands/donate.d.ts +8 -0
  17. package/dist/commands/donate.js +16 -0
  18. package/dist/commands/fetch.d.ts +13 -0
  19. package/dist/commands/fetch.js +49 -0
  20. package/dist/commands/init.d.ts +11 -0
  21. package/dist/commands/init.js +188 -0
  22. package/dist/commands/mcp.d.ts +13 -0
  23. package/dist/commands/mcp.js +32 -0
  24. package/dist/commands/policy.d.ts +8 -0
  25. package/dist/commands/policy.js +101 -0
  26. package/dist/commands/request.d.ts +9 -0
  27. package/dist/commands/request.js +85 -0
  28. package/dist/commands/send.d.ts +11 -0
  29. package/dist/commands/send.js +125 -0
  30. package/dist/commands/serve.d.ts +16 -0
  31. package/dist/commands/serve.js +59 -0
  32. package/dist/commands/watch.d.ts +10 -0
  33. package/dist/commands/watch.js +163 -0
  34. package/dist/config.d.ts +16 -0
  35. package/dist/config.js +51 -0
  36. package/dist/context.d.ts +13 -0
  37. package/dist/context.js +12 -0
  38. package/dist/core/balance.d.ts +24 -0
  39. package/dist/core/balance.js +34 -0
  40. package/dist/core/context.d.ts +27 -0
  41. package/dist/core/context.js +9 -0
  42. package/dist/core/history.d.ts +18 -0
  43. package/dist/core/history.js +15 -0
  44. package/dist/core/index.d.ts +22 -0
  45. package/dist/core/index.js +17 -0
  46. package/dist/core/internal.d.ts +22 -0
  47. package/dist/core/internal.js +19 -0
  48. package/dist/core/policy-status.d.ts +55 -0
  49. package/dist/core/policy-status.js +49 -0
  50. package/dist/core/request.d.ts +43 -0
  51. package/dist/core/request.js +77 -0
  52. package/dist/core/send.d.ts +108 -0
  53. package/dist/core/send.js +277 -0
  54. package/dist/core/spend-lock.d.ts +2 -0
  55. package/dist/core/spend-lock.js +25 -0
  56. package/dist/core/wallet.d.ts +53 -0
  57. package/dist/core/wallet.js +77 -0
  58. package/dist/errors.d.ts +30 -0
  59. package/dist/errors.js +39 -0
  60. package/dist/http402/client.d.ts +32 -0
  61. package/dist/http402/client.js +85 -0
  62. package/dist/http402/middleware.d.ts +37 -0
  63. package/dist/http402/middleware.js +96 -0
  64. package/dist/http402/protocol.d.ts +50 -0
  65. package/dist/http402/protocol.js +114 -0
  66. package/dist/ledger.d.ts +51 -0
  67. package/dist/ledger.js +27 -0
  68. package/dist/mcp/server.d.ts +32 -0
  69. package/dist/mcp/server.js +484 -0
  70. package/dist/output.d.ts +17 -0
  71. package/dist/output.js +44 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +24 -0
  74. package/dist/policy/approvals.d.ts +24 -0
  75. package/dist/policy/approvals.js +89 -0
  76. package/dist/policy/budget.d.ts +16 -0
  77. package/dist/policy/budget.js +47 -0
  78. package/dist/policy/engine.d.ts +76 -0
  79. package/dist/policy/engine.js +199 -0
  80. package/dist/policy/policy.d.ts +30 -0
  81. package/dist/policy/policy.js +126 -0
  82. package/dist/prompt.d.ts +11 -0
  83. package/dist/prompt.js +57 -0
  84. package/dist/tx.d.ts +29 -0
  85. package/dist/tx.js +68 -0
  86. package/dist/units.d.ts +13 -0
  87. package/dist/units.js +53 -0
  88. package/dist/wallet/brc100.d.ts +105 -0
  89. package/dist/wallet/brc100.js +217 -0
  90. package/dist/wallet/crypto.d.ts +25 -0
  91. package/dist/wallet/crypto.js +46 -0
  92. package/dist/wallet/wallet.d.ts +86 -0
  93. package/dist/wallet/wallet.js +186 -0
  94. package/docs/AGENTIC-PAYMENTS.md +218 -0
  95. package/docs/BRC100.md +151 -0
  96. package/package.json +82 -0
package/dist/tx.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { Transaction } from '@bsv/sdk';
2
+ import type { Utxo } from './chain/provider.js';
3
+ import type { Wallet } from './wallet/wallet.js';
4
+ /** A UTXO annotated with the wallet address that owns it. */
5
+ export interface SpendableUtxo extends Utxo {
6
+ address: string;
7
+ }
8
+ /**
9
+ * Outputs below this are not worth creating as change — spending a P2PKH
10
+ * input costs ~148 bytes, so tiny change is folded into the fee instead.
11
+ */
12
+ export declare const DUST_LIMIT_SATS = 135;
13
+ /** Standard P2PKH size estimate: 10 overhead + 148/input + 34/output. */
14
+ export declare function estimateTxSizeBytes(nIn: number, nOut: number): number;
15
+ export declare function feeForTx(nIn: number, nOut: number, rateSatsPerKb: number): number;
16
+ export interface Selection {
17
+ selected: SpendableUtxo[];
18
+ fee: number;
19
+ /** 0 when change was folded into the fee (sub-dust). */
20
+ changeSats: number;
21
+ }
22
+ /**
23
+ * Largest-first selection with a fee feedback loop. Assumes 2 outputs
24
+ * (recipient + change); sub-dust change is folded into the fee.
25
+ * Throws exit 3 when funds cannot cover amount + fee.
26
+ */
27
+ export declare function selectUtxos(utxos: SpendableUtxo[], amountSats: number, rateSatsPerKb: number): Selection;
28
+ /** Build and sign a P2PKH transaction spending the selection. */
29
+ export declare function buildSignedTx(wallet: Wallet, selection: Selection, recipientAddress: string, amountSats: number, changeAddress: string): Promise<Transaction>;
package/dist/tx.js ADDED
@@ -0,0 +1,68 @@
1
+ import { P2PKH, Transaction } from '@bsv/sdk';
2
+ import { CliError, EXIT } from './errors.js';
3
+ import { formatSats } from './units.js';
4
+ /**
5
+ * Outputs below this are not worth creating as change — spending a P2PKH
6
+ * input costs ~148 bytes, so tiny change is folded into the fee instead.
7
+ */
8
+ export const DUST_LIMIT_SATS = 135;
9
+ /** Standard P2PKH size estimate: 10 overhead + 148/input + 34/output. */
10
+ export function estimateTxSizeBytes(nIn, nOut) {
11
+ return 10 + 148 * nIn + 34 * nOut;
12
+ }
13
+ export function feeForTx(nIn, nOut, rateSatsPerKb) {
14
+ return Math.max(1, Math.ceil((estimateTxSizeBytes(nIn, nOut) * rateSatsPerKb) / 1000));
15
+ }
16
+ /**
17
+ * Largest-first selection with a fee feedback loop. Assumes 2 outputs
18
+ * (recipient + change); sub-dust change is folded into the fee.
19
+ * Throws exit 3 when funds cannot cover amount + fee.
20
+ */
21
+ export function selectUtxos(utxos, amountSats, rateSatsPerKb) {
22
+ const pool = [...utxos].sort((a, b) => b.satoshis - a.satoshis);
23
+ const selected = [];
24
+ let total = 0;
25
+ for (const utxo of pool) {
26
+ selected.push(utxo);
27
+ total += utxo.satoshis;
28
+ const fee = feeForTx(selected.length, 2, rateSatsPerKb);
29
+ if (total >= amountSats + fee) {
30
+ const change = total - amountSats - fee;
31
+ if (change < DUST_LIMIT_SATS) {
32
+ // fold sub-dust change into the fee (single-output tx)
33
+ return { selected, fee: total - amountSats, changeSats: 0 };
34
+ }
35
+ return { selected, fee, changeSats: change };
36
+ }
37
+ }
38
+ const available = utxos.reduce((s, u) => s + u.satoshis, 0);
39
+ const feeGuess = feeForTx(Math.max(1, utxos.length), 2, rateSatsPerKb);
40
+ throw new CliError(EXIT.INSUFFICIENT_FUNDS, 'insufficient_funds', `Insufficient funds: trying to send ${formatSats(amountSats)} plus ~${feeGuess} sats fee, ` +
41
+ `but only ${formatSats(available)} is spendable. Fund the wallet or send less.`, { available_sats: available, needed_sats: amountSats + feeGuess });
42
+ }
43
+ /** Build and sign a P2PKH transaction spending the selection. */
44
+ export async function buildSignedTx(wallet, selection, recipientAddress, amountSats, changeAddress) {
45
+ const tx = new Transaction();
46
+ for (const utxo of selection.selected) {
47
+ const key = wallet.privKeyForAddress(utxo.address);
48
+ if (!key) {
49
+ throw new CliError(EXIT.UNEXPECTED, 'missing_key', `No signing key for tracked address ${utxo.address}.`);
50
+ }
51
+ const sourceLock = new P2PKH().lock(utxo.address);
52
+ tx.addInput({
53
+ sourceTXID: utxo.txid,
54
+ sourceOutputIndex: utxo.vout,
55
+ unlockingScriptTemplate: new P2PKH().unlock(key, 'all', false, utxo.satoshis, sourceLock),
56
+ sequence: 0xffffffff,
57
+ });
58
+ }
59
+ tx.addOutput({ lockingScript: new P2PKH().lock(recipientAddress), satoshis: amountSats });
60
+ if (selection.changeSats > 0) {
61
+ tx.addOutput({
62
+ lockingScript: new P2PKH().lock(changeAddress),
63
+ satoshis: selection.changeSats,
64
+ });
65
+ }
66
+ await tx.sign();
67
+ return tx;
68
+ }
@@ -0,0 +1,13 @@
1
+ export declare const SATS_PER_BSV = 100000000;
2
+ /**
3
+ * Parse a user-supplied amount into satoshis.
4
+ *
5
+ * Rules (invariant 1): bare integers are satoshis; `sats` and `bsv` suffixes
6
+ * are accepted (case-insensitive, optional space before suffix). Anything
7
+ * ambiguous or unparseable throws a usage error (exit 2) — never guess.
8
+ */
9
+ export declare function parseAmount(input: string): number;
10
+ /** Format satoshis for human output, e.g. "5,000 sats (0.00005 BSV)". */
11
+ export declare function formatSats(sats: number): string;
12
+ /** Satoshis -> BSV decimal string (for BIP-21 URIs). */
13
+ export declare function satsToBsvString(sats: number): string;
package/dist/units.js ADDED
@@ -0,0 +1,53 @@
1
+ import { usageError } from './errors.js';
2
+ export const SATS_PER_BSV = 100_000_000;
3
+ /**
4
+ * Parse a user-supplied amount into satoshis.
5
+ *
6
+ * Rules (invariant 1): bare integers are satoshis; `sats` and `bsv` suffixes
7
+ * are accepted (case-insensitive, optional space before suffix). Anything
8
+ * ambiguous or unparseable throws a usage error (exit 2) — never guess.
9
+ */
10
+ export function parseAmount(input) {
11
+ const raw = input.trim();
12
+ if (raw === '')
13
+ throw usageError('invalid_amount', 'Amount is empty. Pass satoshis (e.g. 5000), 5000sats, or 0.0001bsv.');
14
+ const m = /^([0-9]+(?:\.[0-9]+)?)\s*(sats?|bsv)?$/i.exec(raw);
15
+ if (!m || !m[1]) {
16
+ throw usageError('invalid_amount', `Cannot parse amount "${input}". Use bare satoshis (5000), a sats suffix (5000sats), or a bsv suffix (0.0001bsv).`);
17
+ }
18
+ const numberPart = m[1];
19
+ const suffix = (m[2] ?? '').toLowerCase();
20
+ let sats;
21
+ if (suffix === 'bsv') {
22
+ const [whole = '0', frac = ''] = numberPart.split('.');
23
+ if (frac.length > 8) {
24
+ throw usageError('invalid_amount', `"${input}" has more than 8 decimal places — BSV is divisible to 8 places (1 satoshi). Reduce the precision.`);
25
+ }
26
+ sats = Number(whole) * SATS_PER_BSV + Number(frac.padEnd(8, '0') || '0');
27
+ }
28
+ else {
29
+ // bare or sats suffix: must be an integer count of satoshis
30
+ if (numberPart.includes('.')) {
31
+ throw usageError('invalid_amount', `"${input}" is fractional but satoshis are indivisible. Use a whole number of sats or a bsv suffix (e.g. 0.0001bsv).`);
32
+ }
33
+ sats = Number(numberPart);
34
+ }
35
+ if (!Number.isSafeInteger(sats)) {
36
+ throw usageError('invalid_amount', `Amount "${input}" is too large to represent safely.`);
37
+ }
38
+ if (sats <= 0) {
39
+ throw usageError('invalid_amount', `Amount must be greater than zero (got "${input}").`);
40
+ }
41
+ return sats;
42
+ }
43
+ /** Format satoshis for human output, e.g. "5,000 sats (0.00005 BSV)". */
44
+ export function formatSats(sats) {
45
+ const bsv = (sats / SATS_PER_BSV).toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
46
+ return `${sats.toLocaleString('en-US')} sats (${bsv} BSV)`;
47
+ }
48
+ /** Satoshis -> BSV decimal string (for BIP-21 URIs). */
49
+ export function satsToBsvString(sats) {
50
+ const whole = Math.floor(sats / SATS_PER_BSV);
51
+ const frac = (sats % SATS_PER_BSV).toString().padStart(8, '0').replace(/0+$/, '');
52
+ return frac ? `${whole}.${frac}` : `${whole}`;
53
+ }
@@ -0,0 +1,105 @@
1
+ import type { CreateActionArgs, CreateActionResult, ListOutputsArgs, ListOutputsResult } from '@bsv/sdk';
2
+ import { CliError } from '../errors.js';
3
+ import type { Network } from '../paths.js';
4
+ /**
5
+ * BRC-100 custody backend (EXPERIMENTAL — Phase 2 M12). Keys live in an
6
+ * external wallet app (BSV Desktop / Metanet Desktop); bsv-pay constructs
7
+ * actions and the external wallet funds, signs, and broadcasts them.
8
+ *
9
+ * The policy engine stays IN FRONT of external custody: the policy gate in
10
+ * core decides and ledgers every spend BEFORE anything reaches this module
11
+ * (invariant 2), exactly as for local-seed wallets. The connection handle
12
+ * is key-capable by proxy — it can make the external wallet sign — so it
13
+ * never crosses the core boundary (invariant 1 extended to wallet handles):
14
+ * this module hands out txids, amounts, and addresses only.
15
+ */
16
+ /** Default JSON-API endpoint exposed by BSV Desktop / Metanet Desktop. */
17
+ export declare const DEFAULT_BRC100_URL = "http://localhost:3321";
18
+ /**
19
+ * The subset of the BRC-100 wallet interface bsv-pay uses (structural, so
20
+ * tests and embedders can inject a mock). Matches @bsv/sdk WalletInterface.
21
+ */
22
+ export interface Brc100Interface {
23
+ getVersion(args: object): Promise<{
24
+ version: string;
25
+ }>;
26
+ getNetwork(args: object): Promise<{
27
+ network: 'mainnet' | 'testnet';
28
+ }>;
29
+ waitForAuthentication(args: object): Promise<{
30
+ authenticated: true;
31
+ }>;
32
+ getPublicKey(args: {
33
+ identityKey: true;
34
+ }): Promise<{
35
+ publicKey: string;
36
+ }>;
37
+ createAction(args: CreateActionArgs): Promise<CreateActionResult>;
38
+ listOutputs(args: ListOutputsArgs): Promise<ListOutputsResult>;
39
+ }
40
+ export interface ConnectBrc100Options {
41
+ /** Wallet JSON-API URL; default BSV_PAY_BRC100_URL env, then localhost:3321. */
42
+ url?: string;
43
+ /** Inject a connected wallet interface (tests, library embedders). */
44
+ wallet?: Brc100Interface;
45
+ }
46
+ export interface Brc100PayParams {
47
+ to: string;
48
+ amountSats: number;
49
+ memo?: string;
50
+ }
51
+ export interface Brc100PayResult {
52
+ txid: string;
53
+ /** Empty string when the wallet returned a txid but no decodable tx. */
54
+ rawTxHex: string;
55
+ /** Exact fee decoded from the returned transaction; undefined if undecodable. */
56
+ feeSats?: number;
57
+ /** Satoshis the wallet routed back to itself (its own change). */
58
+ changeSats: number;
59
+ sizeBytes: number;
60
+ }
61
+ /**
62
+ * Receive-side surfaces (request/watch/serve, MCP request tools) refuse
63
+ * under BRC-100 custody: a payment address issued outside the wallet app
64
+ * would be invisible to it — the funds would land somewhere the wallet
65
+ * cannot see or spend. Receiving stays in the wallet app (documented
66
+ * limitation of the experimental backend; see DECISIONS.md M12).
67
+ */
68
+ export declare function brc100ReceiveNotSupported(): CliError;
69
+ /**
70
+ * An external BRC-100 wallet, connected and network-verified. Exposes only
71
+ * public data (txids, satoshis, the identity public key); the underlying
72
+ * interface stays module-private and is never returned by any method.
73
+ */
74
+ export declare class Brc100Wallet {
75
+ readonly network: Network;
76
+ private readonly iface;
77
+ readonly url: string;
78
+ constructor(network: Network, iface: Brc100Interface, url: string);
79
+ /** Block until the wallet app has authenticated this origin (may prompt). */
80
+ waitForAuthentication(): Promise<void>;
81
+ /** The wallet's identity public key (public data, safe to display). */
82
+ identityKey(): Promise<string>;
83
+ version(): Promise<string>;
84
+ /** Spendable balance as the external wallet reports it (default basket). */
85
+ getBalanceSats(): Promise<number>;
86
+ /**
87
+ * Ask the external wallet to pay `amountSats` to a P2PKH address. The
88
+ * wallet funds, signs, and broadcasts (acceptDelayedBroadcast: false, so
89
+ * failures surface here, not in a background queue). Only called by core
90
+ * executeSend AFTER the policy gate authorized this exact spend.
91
+ *
92
+ * Errors: exit 3 (wallet reports insufficient funds), exit 7 (wallet
93
+ * unreachable — nothing spent), exit 6 (the wallet created the action but
94
+ * its broadcast outcome is unknown; txid in data when available), exit 5
95
+ * (the wallet refused — e.g. the human declined the prompt; nothing spent).
96
+ */
97
+ payToAddress(params: Brc100PayParams): Promise<Brc100PayResult>;
98
+ }
99
+ /**
100
+ * Connect to the external BRC-100 wallet and verify it is on the expected
101
+ * network (invariant 7: a testnet bsv-pay wallet refuses a mainnet wallet
102
+ * app and vice versa). Throws exit 7 `brc100_unreachable` when no wallet
103
+ * answers and exit 2 `brc100_network_mismatch` on a network mismatch.
104
+ */
105
+ export declare function connectBrc100(network: Network, opts?: ConnectBrc100Options): Promise<Brc100Wallet>;
@@ -0,0 +1,217 @@
1
+ import { HTTPWalletJSON, P2PKH, Transaction, WERR_INSUFFICIENT_FUNDS } from '@bsv/sdk';
2
+ import { CliError, EXIT } from '../errors.js';
3
+ /**
4
+ * BRC-100 custody backend (EXPERIMENTAL — Phase 2 M12). Keys live in an
5
+ * external wallet app (BSV Desktop / Metanet Desktop); bsv-pay constructs
6
+ * actions and the external wallet funds, signs, and broadcasts them.
7
+ *
8
+ * The policy engine stays IN FRONT of external custody: the policy gate in
9
+ * core decides and ledgers every spend BEFORE anything reaches this module
10
+ * (invariant 2), exactly as for local-seed wallets. The connection handle
11
+ * is key-capable by proxy — it can make the external wallet sign — so it
12
+ * never crosses the core boundary (invariant 1 extended to wallet handles):
13
+ * this module hands out txids, amounts, and addresses only.
14
+ */
15
+ /** Default JSON-API endpoint exposed by BSV Desktop / Metanet Desktop. */
16
+ export const DEFAULT_BRC100_URL = 'http://localhost:3321';
17
+ /** Originator shown to the user by the wallet app when it asks permission. */
18
+ const ORIGINATOR = 'bsv-pay';
19
+ /**
20
+ * Receive-side surfaces (request/watch/serve, MCP request tools) refuse
21
+ * under BRC-100 custody: a payment address issued outside the wallet app
22
+ * would be invisible to it — the funds would land somewhere the wallet
23
+ * cannot see or spend. Receiving stays in the wallet app (documented
24
+ * limitation of the experimental backend; see DECISIONS.md M12).
25
+ */
26
+ export function brc100ReceiveNotSupported() {
27
+ return new CliError(EXIT.USAGE, 'brc100_receive_not_supported', 'Receiving through bsv-pay is not supported with BRC-100 custody (experimental): an address ' +
28
+ 'issued by bsv-pay would be invisible to the wallet app and the funds unspendable from it. ' +
29
+ "Use the wallet app's own receive screen, or a local-seed wallet for request/watch/serve.");
30
+ }
31
+ function unreachable(url, cause) {
32
+ return new CliError(EXIT.WALLET_LOCKED, 'brc100_unreachable', `No BRC-100 wallet answered at ${url} (${cause}). Start your wallet app ` +
33
+ '(e.g. Metanet Desktop) and make sure its JSON-API is enabled, or set ' +
34
+ 'BSV_PAY_BRC100_URL if it listens elsewhere. Nothing was spent.', { url });
35
+ }
36
+ /** Connection failures (TCP refused, DNS, timeout) — the request never landed. */
37
+ function isConnectionFailure(e) {
38
+ if (!(e instanceof Error))
39
+ return false;
40
+ const text = `${e.message} ${e.cause instanceof Error ? e.cause.message : String(e.cause ?? '')}`;
41
+ return /fetch failed|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|timeout/i.test(text);
42
+ }
43
+ function truncateBytes(text, maxBytes) {
44
+ let out = text;
45
+ while (Buffer.byteLength(out, 'utf8') > maxBytes)
46
+ out = out.slice(0, -1);
47
+ return out;
48
+ }
49
+ /** BRC-100 action description: 5–50 bytes, shown in the wallet app's UI. */
50
+ function actionDescription(memo) {
51
+ return truncateBytes(`bsv-pay: ${memo ?? 'payment'}`, 50);
52
+ }
53
+ /**
54
+ * An external BRC-100 wallet, connected and network-verified. Exposes only
55
+ * public data (txids, satoshis, the identity public key); the underlying
56
+ * interface stays module-private and is never returned by any method.
57
+ */
58
+ export class Brc100Wallet {
59
+ network;
60
+ iface;
61
+ url;
62
+ constructor(network, iface, url) {
63
+ this.network = network;
64
+ this.iface = iface;
65
+ this.url = url;
66
+ }
67
+ /** Block until the wallet app has authenticated this origin (may prompt). */
68
+ async waitForAuthentication() {
69
+ try {
70
+ await this.iface.waitForAuthentication({});
71
+ }
72
+ catch (e) {
73
+ if (isConnectionFailure(e))
74
+ throw unreachable(this.url, 'connection lost');
75
+ throw new CliError(EXIT.WALLET_LOCKED, 'brc100_not_authenticated', `The wallet app did not authenticate bsv-pay: ${e instanceof Error ? e.message : String(e)}.`, { url: this.url });
76
+ }
77
+ }
78
+ /** The wallet's identity public key (public data, safe to display). */
79
+ async identityKey() {
80
+ const { publicKey } = await this.iface.getPublicKey({ identityKey: true });
81
+ return publicKey;
82
+ }
83
+ async version() {
84
+ const { version } = await this.iface.getVersion({});
85
+ return version;
86
+ }
87
+ /** Spendable balance as the external wallet reports it (default basket). */
88
+ async getBalanceSats() {
89
+ let total = 0;
90
+ let offset = 0;
91
+ for (;;) {
92
+ let res;
93
+ try {
94
+ res = await this.iface.listOutputs({ basket: 'default', limit: 10_000, offset });
95
+ }
96
+ catch (e) {
97
+ if (isConnectionFailure(e))
98
+ throw unreachable(this.url, 'connection lost');
99
+ throw new CliError(EXIT.NETWORK, 'brc100_error', `The external wallet could not list its funds: ${e instanceof Error ? e.message : String(e)}.`, { url: this.url });
100
+ }
101
+ for (const o of res.outputs)
102
+ if (o.spendable)
103
+ total += o.satoshis;
104
+ offset += res.outputs.length;
105
+ if (res.outputs.length === 0 || offset >= res.totalOutputs)
106
+ return total;
107
+ }
108
+ }
109
+ /**
110
+ * Ask the external wallet to pay `amountSats` to a P2PKH address. The
111
+ * wallet funds, signs, and broadcasts (acceptDelayedBroadcast: false, so
112
+ * failures surface here, not in a background queue). Only called by core
113
+ * executeSend AFTER the policy gate authorized this exact spend.
114
+ *
115
+ * Errors: exit 3 (wallet reports insufficient funds), exit 7 (wallet
116
+ * unreachable — nothing spent), exit 6 (the wallet created the action but
117
+ * its broadcast outcome is unknown; txid in data when available), exit 5
118
+ * (the wallet refused — e.g. the human declined the prompt; nothing spent).
119
+ */
120
+ async payToAddress(params) {
121
+ let result;
122
+ try {
123
+ result = await this.iface.createAction({
124
+ description: actionDescription(params.memo),
125
+ outputs: [
126
+ {
127
+ lockingScript: new P2PKH().lock(params.to).toHex(),
128
+ satoshis: params.amountSats,
129
+ outputDescription: 'bsv-pay payment',
130
+ },
131
+ ],
132
+ labels: ['bsv-pay'],
133
+ options: { acceptDelayedBroadcast: false },
134
+ });
135
+ }
136
+ catch (e) {
137
+ throw mapActionError(e, this.url);
138
+ }
139
+ if (!result.txid) {
140
+ throw new CliError(EXIT.UNEXPECTED, 'brc100_bad_result', 'The external wallet returned no txid for the created action.');
141
+ }
142
+ // Decode the returned AtomicBEEF for the exact fee/size/change. The
143
+ // payment is already broadcast at this point, so decoding problems must
144
+ // not throw the result away — degrade to txid-only.
145
+ try {
146
+ if (!result.tx)
147
+ throw new Error('no tx in result');
148
+ const tx = Transaction.fromAtomicBEEF(result.tx);
149
+ const rawTxHex = tx.toHex();
150
+ const recipientScript = new P2PKH().lock(params.to).toHex();
151
+ let recipientSeen = false;
152
+ let changeSats = 0;
153
+ for (const out of tx.outputs) {
154
+ const isRecipient = !recipientSeen &&
155
+ out.satoshis === params.amountSats &&
156
+ out.lockingScript.toHex() === recipientScript;
157
+ if (isRecipient)
158
+ recipientSeen = true;
159
+ else
160
+ changeSats += out.satoshis ?? 0;
161
+ }
162
+ return {
163
+ txid: result.txid,
164
+ rawTxHex,
165
+ feeSats: tx.getFee(),
166
+ changeSats,
167
+ sizeBytes: rawTxHex.length / 2,
168
+ };
169
+ }
170
+ catch {
171
+ return { txid: result.txid, rawTxHex: '', feeSats: undefined, changeSats: 0, sizeBytes: 0 };
172
+ }
173
+ }
174
+ }
175
+ /** Map a createAction failure onto the stable exit-code families. */
176
+ function mapActionError(e, url) {
177
+ if (e instanceof CliError)
178
+ return e;
179
+ if (e instanceof WERR_INSUFFICIENT_FUNDS) {
180
+ return new CliError(EXIT.INSUFFICIENT_FUNDS, 'insufficient_funds', `The external wallet reports insufficient funds: ${e.moreSatoshisNeeded} more satoshis needed. Fund the wallet or send less.`, { needed_sats: e.totalSatoshisNeeded, more_sats_needed: e.moreSatoshisNeeded });
181
+ }
182
+ // WERR_REVIEW_ACTIONS (duck-typed: injected mocks may not share the class):
183
+ // the wallet created the action but the broadcast outcome needs review —
184
+ // the money may have moved. Conservative: status unknown, txid if present.
185
+ if (e instanceof Error && 'reviewActionResults' in e) {
186
+ const txid = e.txid;
187
+ return new CliError(EXIT.BROADCAST_UNKNOWN, 'brc100_broadcast_unknown', `The external wallet created the payment but its broadcast outcome is unknown${txid ? ` (txid ${txid})` : ''}. ` +
188
+ 'Check the wallet app before retrying; the funds may already have moved.', txid ? { txid } : undefined);
189
+ }
190
+ if (isConnectionFailure(e))
191
+ return unreachable(url, 'connection failed mid-request');
192
+ return new CliError(EXIT.BROADCAST_REJECTED, 'brc100_action_rejected', `The external wallet did not complete the payment: ${e instanceof Error ? e.message : String(e)}. ` +
193
+ 'If you declined the request in the wallet app, nothing was sent.');
194
+ }
195
+ /**
196
+ * Connect to the external BRC-100 wallet and verify it is on the expected
197
+ * network (invariant 7: a testnet bsv-pay wallet refuses a mainnet wallet
198
+ * app and vice versa). Throws exit 7 `brc100_unreachable` when no wallet
199
+ * answers and exit 2 `brc100_network_mismatch` on a network mismatch.
200
+ */
201
+ export async function connectBrc100(network, opts = {}) {
202
+ const url = opts.url ?? process.env.BSV_PAY_BRC100_URL ?? DEFAULT_BRC100_URL;
203
+ const iface = opts.wallet ?? new HTTPWalletJSON(ORIGINATOR, url);
204
+ let walletNetwork;
205
+ try {
206
+ ({ network: walletNetwork } = await iface.getNetwork({}));
207
+ }
208
+ catch (e) {
209
+ throw unreachable(url, e instanceof Error ? e.message : String(e));
210
+ }
211
+ const expected = network === 'test' ? 'testnet' : 'mainnet';
212
+ if (walletNetwork !== expected) {
213
+ throw new CliError(EXIT.USAGE, 'brc100_network_mismatch', `The external wallet is on ${walletNetwork} but this bsv-pay wallet is ${expected}. ` +
214
+ 'Switch the wallet app to the matching network (state never mixes across networks).', { wallet_network: walletNetwork, expected_network: expected });
215
+ }
216
+ return new Brc100Wallet(network, iface, url);
217
+ }
@@ -0,0 +1,25 @@
1
+ export interface KdfParams {
2
+ algo: 'argon2id';
3
+ salt: string;
4
+ t: number;
5
+ m: number;
6
+ p: number;
7
+ }
8
+ export interface CipherBlob {
9
+ algo: 'aes-256-gcm';
10
+ iv: string;
11
+ tag: string;
12
+ ciphertext: string;
13
+ }
14
+ /**
15
+ * OWASP-recommended argon2id configuration (19 MiB, t=2, p=1). Parameters are
16
+ * stored alongside the wallet so they can be raised later without breaking
17
+ * existing wallets.
18
+ */
19
+ export declare const DEFAULT_KDF: Pick<KdfParams, 't' | 'm' | 'p'>;
20
+ export declare function encryptSecret(plaintext: string, passphrase: string): {
21
+ kdf: KdfParams;
22
+ cipher: CipherBlob;
23
+ };
24
+ /** Throws exit 7 (wallet locked) when the passphrase is wrong. */
25
+ export declare function decryptSecret(kdf: KdfParams, cipher: CipherBlob, passphrase: string): string;
@@ -0,0 +1,46 @@
1
+ import crypto from 'node:crypto';
2
+ import { argon2id } from '@noble/hashes/argon2';
3
+ import { CliError, EXIT } from '../errors.js';
4
+ /**
5
+ * OWASP-recommended argon2id configuration (19 MiB, t=2, p=1). Parameters are
6
+ * stored alongside the wallet so they can be raised later without breaking
7
+ * existing wallets.
8
+ */
9
+ export const DEFAULT_KDF = { t: 2, m: 19_456, p: 1 };
10
+ function deriveKey(passphrase, params) {
11
+ const pass = new TextEncoder().encode(passphrase.normalize('NFKD'));
12
+ const salt = Buffer.from(params.salt, 'hex');
13
+ return Buffer.from(argon2id(pass, salt, { t: params.t, m: params.m, p: params.p, dkLen: 32 }));
14
+ }
15
+ export function encryptSecret(plaintext, passphrase) {
16
+ const kdf = {
17
+ algo: 'argon2id',
18
+ salt: crypto.randomBytes(16).toString('hex'),
19
+ ...DEFAULT_KDF,
20
+ };
21
+ const key = deriveKey(passphrase, kdf);
22
+ const iv = crypto.randomBytes(12);
23
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv);
24
+ const ciphertext = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]);
25
+ return {
26
+ kdf,
27
+ cipher: {
28
+ algo: 'aes-256-gcm',
29
+ iv: iv.toString('hex'),
30
+ tag: c.getAuthTag().toString('hex'),
31
+ ciphertext: ciphertext.toString('hex'),
32
+ },
33
+ };
34
+ }
35
+ /** Throws exit 7 (wallet locked) when the passphrase is wrong. */
36
+ export function decryptSecret(kdf, cipher, passphrase) {
37
+ const key = deriveKey(passphrase, kdf);
38
+ try {
39
+ const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'hex'));
40
+ d.setAuthTag(Buffer.from(cipher.tag, 'hex'));
41
+ return Buffer.concat([d.update(Buffer.from(cipher.ciphertext, 'hex')), d.final()]).toString('utf8');
42
+ }
43
+ catch {
44
+ throw new CliError(EXIT.WALLET_LOCKED, 'bad_passphrase', 'Wrong passphrase. Try again, or set BSV_PAY_PASSPHRASE for scripted use.');
45
+ }
46
+ }
@@ -0,0 +1,86 @@
1
+ import { PrivateKey } from '@bsv/sdk';
2
+ import { type Network } from '../paths.js';
3
+ import { type CipherBlob, type KdfParams } from './crypto.js';
4
+ export interface SecretPayload {
5
+ type: 'mnemonic' | 'wif';
6
+ value: string;
7
+ }
8
+ export interface WalletFile {
9
+ version: 1;
10
+ network: Network;
11
+ encrypted: boolean;
12
+ /**
13
+ * 'brc100' delegates custody to an external BRC-100 wallet app
14
+ * (EXPERIMENTAL, M12): no secret is stored and the local signing Wallet
15
+ * never exists. Absent = local-seed custody.
16
+ */
17
+ backend?: 'brc100';
18
+ /** JSON-API URL of the external wallet (backend === 'brc100' only). */
19
+ brc100_url?: string;
20
+ /** Present only when encrypted === false (explicit opt-in at init). */
21
+ secret?: SecretPayload;
22
+ kdf?: KdfParams;
23
+ cipher?: CipherBlob;
24
+ next_receive_index: number;
25
+ next_change_index: number;
26
+ created_at: string;
27
+ }
28
+ export declare function walletExists(network: Network): boolean;
29
+ export declare function readWalletFile(network: Network): WalletFile;
30
+ export declare function writeWalletFile(network: Network, wallet: WalletFile): void;
31
+ export declare function buildWalletFile(network: Network, secret: SecretPayload, passphrase: string | null): WalletFile;
32
+ /** Wallet file for external BRC-100 custody: no secret, no counters in use. */
33
+ export declare function buildBrc100WalletFile(network: Network, url: string): WalletFile;
34
+ export declare const UNENCRYPTED_WALLET_WARNING: string;
35
+ /**
36
+ * How unlock obtains the passphrase and reports warnings. Defaults preserve
37
+ * CLI behavior (env var, then interactive prompt; warnings to stderr); the
38
+ * core library passes explicit values so it never prompts or prints.
39
+ */
40
+ export interface UnlockOptions {
41
+ /** Passphrase or async supplier; default: BSV_PAY_PASSPHRASE env, then interactive prompt. */
42
+ passphrase?: string | (() => Promise<string>);
43
+ /** Sink for human warnings (e.g. unencrypted wallet); default: stderr. */
44
+ onWarning?: (text: string) => void;
45
+ }
46
+ /**
47
+ * Resolve the passphrase: env var for scripts, otherwise interactive prompt.
48
+ * Exported so CLI commands can hand this exact flow to core's openWallet().
49
+ */
50
+ export declare function obtainPassphrase(supplied?: string | (() => Promise<string>)): Promise<string>;
51
+ export interface TrackedAddress {
52
+ address: string;
53
+ chain: 0 | 1;
54
+ index: number;
55
+ }
56
+ /** An unlocked wallet: can derive addresses and signing keys. */
57
+ export declare class Wallet {
58
+ readonly network: Network;
59
+ private file;
60
+ private readonly secret;
61
+ private readonly hd;
62
+ private constructor();
63
+ static unlock(network: Network, options?: UnlockOptions): Promise<Wallet>;
64
+ /**
65
+ * Read-only view (no passphrase needed): tracked addresses can be derived
66
+ * only for unencrypted wallets; encrypted wallets still require unlock, so
67
+ * commands that just need addresses use the ledger instead. Kept private —
68
+ * commands should use Wallet.unlock or the ledger.
69
+ */
70
+ get isHd(): boolean;
71
+ keyAt(chain: 0 | 1, index: number): PrivateKey;
72
+ addressAt(chain: 0 | 1, index: number): string;
73
+ /** Every address this wallet has ever issued (receive + change chains). */
74
+ trackedAddresses(): TrackedAddress[];
75
+ privKeyForAddress(address: string): PrivateKey | undefined;
76
+ /** Next address for a purpose WITHOUT persisting anything (dry runs). */
77
+ peekAddress(purpose: 'receive' | 'change'): {
78
+ address: string;
79
+ index: number;
80
+ };
81
+ /** Derive a fresh address, persist the counter, and record it in the ledger. */
82
+ issueAddress(purpose: 'receive' | 'change' | 'request', memo?: string): {
83
+ address: string;
84
+ index: number;
85
+ };
86
+ }