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.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/dist/address.d.ts +6 -0
- package/dist/address.js +35 -0
- package/dist/chain/provider.d.ts +35 -0
- package/dist/chain/provider.js +1 -0
- package/dist/chain/whatsonchain.d.ts +23 -0
- package/dist/chain/whatsonchain.js +98 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +169 -0
- package/dist/commands/approvals.d.ts +19 -0
- package/dist/commands/approvals.js +112 -0
- package/dist/commands/balance.d.ts +3 -0
- package/dist/commands/balance.js +28 -0
- package/dist/commands/donate.d.ts +8 -0
- package/dist/commands/donate.js +16 -0
- package/dist/commands/fetch.d.ts +13 -0
- package/dist/commands/fetch.js +49 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +188 -0
- package/dist/commands/mcp.d.ts +13 -0
- package/dist/commands/mcp.js +32 -0
- package/dist/commands/policy.d.ts +8 -0
- package/dist/commands/policy.js +101 -0
- package/dist/commands/request.d.ts +9 -0
- package/dist/commands/request.js +85 -0
- package/dist/commands/send.d.ts +11 -0
- package/dist/commands/send.js +125 -0
- package/dist/commands/serve.d.ts +16 -0
- package/dist/commands/serve.js +59 -0
- package/dist/commands/watch.d.ts +10 -0
- package/dist/commands/watch.js +163 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +51 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +12 -0
- package/dist/core/balance.d.ts +24 -0
- package/dist/core/balance.js +34 -0
- package/dist/core/context.d.ts +27 -0
- package/dist/core/context.js +9 -0
- package/dist/core/history.d.ts +18 -0
- package/dist/core/history.js +15 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +17 -0
- package/dist/core/internal.d.ts +22 -0
- package/dist/core/internal.js +19 -0
- package/dist/core/policy-status.d.ts +55 -0
- package/dist/core/policy-status.js +49 -0
- package/dist/core/request.d.ts +43 -0
- package/dist/core/request.js +77 -0
- package/dist/core/send.d.ts +108 -0
- package/dist/core/send.js +277 -0
- package/dist/core/spend-lock.d.ts +2 -0
- package/dist/core/spend-lock.js +25 -0
- package/dist/core/wallet.d.ts +53 -0
- package/dist/core/wallet.js +77 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +39 -0
- package/dist/http402/client.d.ts +32 -0
- package/dist/http402/client.js +85 -0
- package/dist/http402/middleware.d.ts +37 -0
- package/dist/http402/middleware.js +96 -0
- package/dist/http402/protocol.d.ts +50 -0
- package/dist/http402/protocol.js +114 -0
- package/dist/ledger.d.ts +51 -0
- package/dist/ledger.js +27 -0
- package/dist/mcp/server.d.ts +32 -0
- package/dist/mcp/server.js +484 -0
- package/dist/output.d.ts +17 -0
- package/dist/output.js +44 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +24 -0
- package/dist/policy/approvals.d.ts +24 -0
- package/dist/policy/approvals.js +89 -0
- package/dist/policy/budget.d.ts +16 -0
- package/dist/policy/budget.js +47 -0
- package/dist/policy/engine.d.ts +76 -0
- package/dist/policy/engine.js +199 -0
- package/dist/policy/policy.d.ts +30 -0
- package/dist/policy/policy.js +126 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +57 -0
- package/dist/tx.d.ts +29 -0
- package/dist/tx.js +68 -0
- package/dist/units.d.ts +13 -0
- package/dist/units.js +53 -0
- package/dist/wallet/brc100.d.ts +105 -0
- package/dist/wallet/brc100.js +217 -0
- package/dist/wallet/crypto.d.ts +25 -0
- package/dist/wallet/crypto.js +46 -0
- package/dist/wallet/wallet.d.ts +86 -0
- package/dist/wallet/wallet.js +186 -0
- package/docs/AGENTIC-PAYMENTS.md +218 -0
- package/docs/BRC100.md +151 -0
- package/package.json +82 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { CliError, EXIT } from '../errors.js';
|
|
2
|
+
import { appendLedger } from '../ledger.js';
|
|
3
|
+
import { satsToBsvString } from '../units.js';
|
|
4
|
+
import { brc100ReceiveNotSupported } from '../wallet/brc100.js';
|
|
5
|
+
import { resolveCore } from './context.js';
|
|
6
|
+
import { unwrapWallet } from './internal.js';
|
|
7
|
+
/** BIP-21-style URI with the BSV `sv` discriminator. Amount is in BSV. */
|
|
8
|
+
export function buildPaymentUri(address, amountSats, memo) {
|
|
9
|
+
let uri = `bitcoin:${address}?sv&amount=${satsToBsvString(amountSats)}`;
|
|
10
|
+
if (memo)
|
|
11
|
+
uri += `&label=${encodeURIComponent(memo)}`;
|
|
12
|
+
return uri;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Issue a fresh receiving address (one per request, so matching is
|
|
16
|
+
* unambiguous) and build the BIP-21 URI. The address is persisted to the
|
|
17
|
+
* wallet counter and ledger immediately.
|
|
18
|
+
*/
|
|
19
|
+
export function createRequest(wallet, params) {
|
|
20
|
+
if (!Number.isSafeInteger(params.amountSats) || params.amountSats <= 0) {
|
|
21
|
+
throw new CliError(EXIT.USAGE, 'invalid_amount', `amountSats must be a positive integer of satoshis (got ${params.amountSats}).`);
|
|
22
|
+
}
|
|
23
|
+
if (wallet.backend === 'brc100')
|
|
24
|
+
throw brc100ReceiveNotSupported();
|
|
25
|
+
const inner = unwrapWallet(wallet);
|
|
26
|
+
const { address } = inner.issueAddress('request', params.memo);
|
|
27
|
+
return {
|
|
28
|
+
address,
|
|
29
|
+
amountSats: params.amountSats,
|
|
30
|
+
memo: params.memo,
|
|
31
|
+
uri: buildPaymentUri(address, params.amountSats, params.memo),
|
|
32
|
+
network: wallet.network,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function sleep(ms) {
|
|
36
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Poll an address until the first incoming payment appears at 0-conf, then
|
|
40
|
+
* record the receive in the ledger (invariant 6) and return it. Transient
|
|
41
|
+
* chain failures keep polling until the deadline. Timeout throws code 4
|
|
42
|
+
* `request_timeout`.
|
|
43
|
+
*/
|
|
44
|
+
export async function awaitPayment(opts, params) {
|
|
45
|
+
const { network, config, provider } = resolveCore(opts);
|
|
46
|
+
const pollIntervalMs = params.pollIntervalMs ?? config.pollIntervalSecs * 1000;
|
|
47
|
+
const deadlineMs = Date.now() + params.timeoutMs;
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
const utxos = await provider.getUtxos(params.address);
|
|
51
|
+
if (utxos.length > 0) {
|
|
52
|
+
const txid = utxos[0].txid;
|
|
53
|
+
const receivedSats = utxos
|
|
54
|
+
.filter((u) => u.txid === txid)
|
|
55
|
+
.reduce((s, u) => s + u.satoshis, 0);
|
|
56
|
+
const confirmed = (utxos[0].height ?? 0) > 0;
|
|
57
|
+
appendLedger(network, {
|
|
58
|
+
type: 'receive',
|
|
59
|
+
txid,
|
|
60
|
+
amount_sats: receivedSats,
|
|
61
|
+
address: params.address,
|
|
62
|
+
memo: params.memo,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
status: confirmed ? 'confirmed' : 'pending',
|
|
65
|
+
});
|
|
66
|
+
return { address: params.address, txid, receivedSats, confirmed };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// transient network/rate-limit failure: keep polling until the deadline
|
|
71
|
+
}
|
|
72
|
+
if (Date.now() >= deadlineMs) {
|
|
73
|
+
throw new CliError(EXIT.NETWORK, 'request_timeout', `No payment seen on ${params.address} within ${Math.round(params.timeoutMs / 1000)}s.`, { address: params.address, timeout_ms: params.timeoutMs });
|
|
74
|
+
}
|
|
75
|
+
await sleep(Math.min(pollIntervalMs, Math.max(0, deadlineMs - Date.now())));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Network } from '../paths.js';
|
|
2
|
+
import { type SpendableUtxo } from '../tx.js';
|
|
3
|
+
import { type CoreOptions } from './context.js';
|
|
4
|
+
import type { CoreWallet } from './wallet.js';
|
|
5
|
+
export declare function explorerTxUrl(network: Network, txid: string): string;
|
|
6
|
+
export interface SendParams {
|
|
7
|
+
to: string;
|
|
8
|
+
amountSats: number;
|
|
9
|
+
memo?: string;
|
|
10
|
+
/** Spend only confirmed UTXOs (unconfirmed are spendable by default). */
|
|
11
|
+
confirmedOnly?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The human confirmed the legacy soft spend limit (interactive prompt /
|
|
14
|
+
* --allow-large). Satisfies ONLY that confirmable limit — it can never
|
|
15
|
+
* cross a policy.toml rule (hard per-tx limit, budgets, lists, threshold).
|
|
16
|
+
*/
|
|
17
|
+
allowAboveLimit?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Authorize in evaluate-only mode: same policy verdicts, but nothing is
|
|
20
|
+
* persisted and the resulting plan can never be executed for real.
|
|
21
|
+
*/
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** A fully planned, not-yet-signed spend. Carries addresses and txids only. */
|
|
25
|
+
export interface SendPlan {
|
|
26
|
+
to: string;
|
|
27
|
+
amountSats: number;
|
|
28
|
+
memo?: string;
|
|
29
|
+
feeSats: number;
|
|
30
|
+
/** 0 when sub-dust change was folded into the fee. */
|
|
31
|
+
changeSats: number;
|
|
32
|
+
balanceAfterSats: number;
|
|
33
|
+
inputCount: number;
|
|
34
|
+
/** UTXOs the transaction will spend. */
|
|
35
|
+
inputs: SpendableUtxo[];
|
|
36
|
+
/** Where change will go (derived but not persisted until execution). */
|
|
37
|
+
changeAddress: string;
|
|
38
|
+
/**
|
|
39
|
+
* BRC-100 custody (additive, M12): the external wallet funds and signs,
|
|
40
|
+
* so inputs/change are its business and feeSats is an estimate until the
|
|
41
|
+
* wallet returns the real transaction.
|
|
42
|
+
*/
|
|
43
|
+
external?: true;
|
|
44
|
+
}
|
|
45
|
+
export interface SendResult {
|
|
46
|
+
txid: string;
|
|
47
|
+
to: string;
|
|
48
|
+
amountSats: number;
|
|
49
|
+
feeSats: number;
|
|
50
|
+
changeSats: number;
|
|
51
|
+
balanceAfterSats: number;
|
|
52
|
+
sizeBytes: number;
|
|
53
|
+
dryRun: boolean;
|
|
54
|
+
explorerUrl: string;
|
|
55
|
+
/**
|
|
56
|
+
* The signed transaction (public data once broadcast). Carried so a
|
|
57
|
+
* BRC-105 client can present the payment to the server (M11); contains
|
|
58
|
+
* signatures and public keys, never key material.
|
|
59
|
+
*/
|
|
60
|
+
rawTxHex: string;
|
|
61
|
+
/** BRC-100 custody (additive, M12): the external wallet signed/broadcast. */
|
|
62
|
+
external?: true;
|
|
63
|
+
/**
|
|
64
|
+
* True when feeSats is bsv-pay's estimate, not the wallet's final fee
|
|
65
|
+
* (BRC-100 dry runs, or a wallet that returned an undecodable tx).
|
|
66
|
+
*/
|
|
67
|
+
feeEstimated?: true;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate and plan a spend. The policy gate (invariant 2) runs HERE, before
|
|
71
|
+
* any network call: authorizeSpend ledgers the decision and throws exit 8 on
|
|
72
|
+
* deny or exit 9 when queued for approval; on allow the returned plan is
|
|
73
|
+
* registered as authorized for exactly this recipient and amount. Throws
|
|
74
|
+
* code 2 (bad address/amount) or 3 (insufficient funds) otherwise.
|
|
75
|
+
*/
|
|
76
|
+
export declare function planSend(wallet: CoreWallet, opts: CoreOptions, params: SendParams): Promise<SendPlan>;
|
|
77
|
+
/**
|
|
78
|
+
* Plan a previously queued spend after the human passed the approval gate
|
|
79
|
+
* (TTY + approval secret). Re-runs every policy rule except the threshold.
|
|
80
|
+
* Internal: used by the approvals command, NOT exported from bsv-pay/core.
|
|
81
|
+
*/
|
|
82
|
+
export declare function planApprovedSend(wallet: CoreWallet, opts: CoreOptions, params: {
|
|
83
|
+
to: string;
|
|
84
|
+
amountSats: number;
|
|
85
|
+
memo?: string;
|
|
86
|
+
confirmedOnly?: boolean;
|
|
87
|
+
}, approvalId: string): Promise<SendPlan>;
|
|
88
|
+
/**
|
|
89
|
+
* Sign and (unless dryRun) broadcast a planned spend, recording it in the
|
|
90
|
+
* ledger (invariant 6). Refuses any plan the policy gate did not authorize:
|
|
91
|
+
* hand-built, altered, already-executed, or planned with dryRun. dryRun
|
|
92
|
+
* persists nothing — no ledger entry, no change-address counter bump. An
|
|
93
|
+
* ambiguous broadcast failure appends a `status: "unknown"` entry and throws
|
|
94
|
+
* code 6 carrying the txid; a definite rejection throws code 5 with nothing
|
|
95
|
+
* spent or recorded.
|
|
96
|
+
*/
|
|
97
|
+
export declare function executeSend(wallet: CoreWallet, opts: CoreOptions, plan: SendPlan, exec?: {
|
|
98
|
+
dryRun?: boolean;
|
|
99
|
+
}): Promise<SendResult>;
|
|
100
|
+
/**
|
|
101
|
+
* Plan and execute in one call. Never prompts; the policy gate applies.
|
|
102
|
+
* Single-flight per state dir + network: concurrent send() calls in one
|
|
103
|
+
* process (an MCP server, a library embedder) are serialized across the
|
|
104
|
+
* whole decide→broadcast→ledger span, so a later spend is decided only
|
|
105
|
+
* after every earlier one has hit the ledger — two simultaneous spends can
|
|
106
|
+
* never both pass the same remaining budget.
|
|
107
|
+
*/
|
|
108
|
+
export declare function send(wallet: CoreWallet, opts: CoreOptions, params: SendParams): Promise<SendResult>;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { validateAddress } from '../address.js';
|
|
2
|
+
import { CliError, EXIT } from '../errors.js';
|
|
3
|
+
import { appendLedger } from '../ledger.js';
|
|
4
|
+
import { addSessionSpent } from '../policy/budget.js';
|
|
5
|
+
import { authorizeApprovedSpend, authorizeSpend, registerAuthorizedPlan, takeAuthorizedPlan, } from '../policy/engine.js';
|
|
6
|
+
import { buildSignedTx, feeForTx, selectUtxos } from '../tx.js';
|
|
7
|
+
import { resolveCore } from './context.js';
|
|
8
|
+
import { unwrapBackend } from './internal.js';
|
|
9
|
+
import { withSpendLock } from './spend-lock.js';
|
|
10
|
+
export function explorerTxUrl(network, txid) {
|
|
11
|
+
return network === 'test'
|
|
12
|
+
? `https://test.whatsonchain.com/tx/${txid}`
|
|
13
|
+
: `https://whatsonchain.com/tx/${txid}`;
|
|
14
|
+
}
|
|
15
|
+
async function gatherSpendableUtxos(wallet, chain, confirmedOnly) {
|
|
16
|
+
const utxos = [];
|
|
17
|
+
for (const tracked of wallet.trackedAddresses()) {
|
|
18
|
+
const rows = await chain.getUtxos(tracked.address);
|
|
19
|
+
for (const u of rows) {
|
|
20
|
+
// spend unconfirmed change by default; confirmedOnly restricts
|
|
21
|
+
if (confirmedOnly && (u.height === undefined || u.height <= 0))
|
|
22
|
+
continue;
|
|
23
|
+
utxos.push({ ...u, address: tracked.address });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return utxos;
|
|
27
|
+
}
|
|
28
|
+
function validateSpendInput(to, amountSats, network) {
|
|
29
|
+
validateAddress(to, network);
|
|
30
|
+
if (!Number.isSafeInteger(amountSats) || amountSats <= 0) {
|
|
31
|
+
throw new CliError(EXIT.USAGE, 'invalid_amount', `amountSats must be a positive integer of satoshis (got ${amountSats}).`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Gather, select, and shape the plan. No policy, no persistence. */
|
|
35
|
+
async function buildPlan(wallet, resolved, params) {
|
|
36
|
+
const backend = unwrapBackend(wallet);
|
|
37
|
+
if (backend.kind === 'brc100') {
|
|
38
|
+
// The external wallet funds and signs; coin selection and the real fee
|
|
39
|
+
// are its business. Pre-check only what is definitely insufficient and
|
|
40
|
+
// estimate the fee for display — the wallet's figure lands in the result.
|
|
41
|
+
const balance = await backend.wallet.getBalanceSats();
|
|
42
|
+
if (balance < params.amountSats) {
|
|
43
|
+
throw new CliError(EXIT.INSUFFICIENT_FUNDS, 'insufficient_funds', `Insufficient funds: trying to send ${params.amountSats} sats but the external wallet reports only ${balance} sats spendable. Fund the wallet or send less.`, { available_sats: balance, needed_sats: params.amountSats });
|
|
44
|
+
}
|
|
45
|
+
const feeEstimate = feeForTx(1, 2, resolved.config.feeRateSatsPerKb);
|
|
46
|
+
return {
|
|
47
|
+
to: params.to,
|
|
48
|
+
amountSats: params.amountSats,
|
|
49
|
+
memo: params.memo,
|
|
50
|
+
feeSats: feeEstimate,
|
|
51
|
+
changeSats: 0,
|
|
52
|
+
balanceAfterSats: balance - params.amountSats - feeEstimate,
|
|
53
|
+
inputCount: 0,
|
|
54
|
+
inputs: [],
|
|
55
|
+
changeAddress: '',
|
|
56
|
+
external: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const inner = backend.wallet;
|
|
60
|
+
const utxos = await gatherSpendableUtxos(inner, resolved.provider, Boolean(params.confirmedOnly));
|
|
61
|
+
const selection = selectUtxos(utxos, params.amountSats, resolved.config.feeRateSatsPerKb);
|
|
62
|
+
const totalAvailable = utxos.reduce((s, u) => s + u.satoshis, 0);
|
|
63
|
+
return {
|
|
64
|
+
to: params.to,
|
|
65
|
+
amountSats: params.amountSats,
|
|
66
|
+
memo: params.memo,
|
|
67
|
+
feeSats: selection.fee,
|
|
68
|
+
changeSats: selection.changeSats,
|
|
69
|
+
balanceAfterSats: totalAvailable - params.amountSats - selection.fee,
|
|
70
|
+
inputCount: selection.selected.length,
|
|
71
|
+
inputs: selection.selected,
|
|
72
|
+
changeAddress: inner.peekAddress('change').address,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Validate and plan a spend. The policy gate (invariant 2) runs HERE, before
|
|
77
|
+
* any network call: authorizeSpend ledgers the decision and throws exit 8 on
|
|
78
|
+
* deny or exit 9 when queued for approval; on allow the returned plan is
|
|
79
|
+
* registered as authorized for exactly this recipient and amount. Throws
|
|
80
|
+
* code 2 (bad address/amount) or 3 (insufficient funds) otherwise.
|
|
81
|
+
*/
|
|
82
|
+
export async function planSend(wallet, opts, params) {
|
|
83
|
+
const resolved = resolveCore(opts);
|
|
84
|
+
validateSpendInput(params.to, params.amountSats, resolved.network);
|
|
85
|
+
const auth = authorizeSpend({ network: resolved.network, config: resolved.config }, {
|
|
86
|
+
to: params.to,
|
|
87
|
+
amountSats: params.amountSats,
|
|
88
|
+
memo: params.memo,
|
|
89
|
+
softLimitConfirmed: params.allowAboveLimit,
|
|
90
|
+
confirmedOnly: params.confirmedOnly,
|
|
91
|
+
}, { mode: params.dryRun ? 'evaluate' : 'enforce' });
|
|
92
|
+
const plan = await buildPlan(wallet, resolved, params);
|
|
93
|
+
registerAuthorizedPlan(plan, auth);
|
|
94
|
+
return plan;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Plan a previously queued spend after the human passed the approval gate
|
|
98
|
+
* (TTY + approval secret). Re-runs every policy rule except the threshold.
|
|
99
|
+
* Internal: used by the approvals command, NOT exported from bsv-pay/core.
|
|
100
|
+
*/
|
|
101
|
+
export async function planApprovedSend(wallet, opts, params, approvalId) {
|
|
102
|
+
const resolved = resolveCore(opts);
|
|
103
|
+
validateSpendInput(params.to, params.amountSats, resolved.network);
|
|
104
|
+
const auth = authorizeApprovedSpend({ network: resolved.network, config: resolved.config }, { to: params.to, amountSats: params.amountSats, memo: params.memo }, approvalId);
|
|
105
|
+
const plan = await buildPlan(wallet, resolved, params);
|
|
106
|
+
registerAuthorizedPlan(plan, auth);
|
|
107
|
+
return plan;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Sign and (unless dryRun) broadcast a planned spend, recording it in the
|
|
111
|
+
* ledger (invariant 6). Refuses any plan the policy gate did not authorize:
|
|
112
|
+
* hand-built, altered, already-executed, or planned with dryRun. dryRun
|
|
113
|
+
* persists nothing — no ledger entry, no change-address counter bump. An
|
|
114
|
+
* ambiguous broadcast failure appends a `status: "unknown"` entry and throws
|
|
115
|
+
* code 6 carrying the txid; a definite rejection throws code 5 with nothing
|
|
116
|
+
* spent or recorded.
|
|
117
|
+
*/
|
|
118
|
+
export async function executeSend(wallet, opts, plan, exec = {}) {
|
|
119
|
+
const { network, provider } = resolveCore(opts);
|
|
120
|
+
// The other half of the policy gate: no authorization, no broadcast.
|
|
121
|
+
const auth = takeAuthorizedPlan(plan, { to: plan.to, amountSats: plan.amountSats }, !exec.dryRun);
|
|
122
|
+
const backend = unwrapBackend(wallet);
|
|
123
|
+
if (backend.kind === 'brc100') {
|
|
124
|
+
return executeBrc100Send(backend.wallet, network, plan, auth.decisionId, Boolean(exec.dryRun));
|
|
125
|
+
}
|
|
126
|
+
const inner = backend.wallet;
|
|
127
|
+
const tx = await buildSignedTx(inner, { selected: plan.inputs, fee: plan.feeSats, changeSats: plan.changeSats }, plan.to, plan.amountSats, plan.changeAddress);
|
|
128
|
+
const txid = tx.id('hex');
|
|
129
|
+
const rawTxHex = tx.toHex();
|
|
130
|
+
const result = {
|
|
131
|
+
txid,
|
|
132
|
+
to: plan.to,
|
|
133
|
+
amountSats: plan.amountSats,
|
|
134
|
+
feeSats: plan.feeSats,
|
|
135
|
+
changeSats: plan.changeSats,
|
|
136
|
+
balanceAfterSats: plan.balanceAfterSats,
|
|
137
|
+
sizeBytes: rawTxHex.length / 2,
|
|
138
|
+
dryRun: Boolean(exec.dryRun),
|
|
139
|
+
explorerUrl: explorerTxUrl(network, txid),
|
|
140
|
+
rawTxHex,
|
|
141
|
+
};
|
|
142
|
+
if (exec.dryRun)
|
|
143
|
+
return result;
|
|
144
|
+
// Persist the change address only on a real broadcast path.
|
|
145
|
+
if (plan.changeSats > 0)
|
|
146
|
+
inner.issueAddress('change', `change for ${txid.slice(0, 12)}`);
|
|
147
|
+
try {
|
|
148
|
+
const broadcast = await provider.broadcast(tx.toHex());
|
|
149
|
+
if (!broadcast.ok) {
|
|
150
|
+
throw new CliError(EXIT.BROADCAST_REJECTED, 'broadcast_rejected', `The network rejected the transaction: ${broadcast.error ?? 'no reason given'}. ` +
|
|
151
|
+
'Nothing was spent. Check the fee rate in config.toml or retry shortly.', { txid });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (e instanceof CliError && e.exitCode === EXIT.BROADCAST_REJECTED)
|
|
156
|
+
throw e;
|
|
157
|
+
// Ambiguous network failure: the tx may have propagated. Code 6, txid included.
|
|
158
|
+
appendLedger(network, {
|
|
159
|
+
type: 'send',
|
|
160
|
+
txid,
|
|
161
|
+
amount_sats: plan.amountSats,
|
|
162
|
+
address: plan.to,
|
|
163
|
+
memo: plan.memo,
|
|
164
|
+
timestamp: new Date().toISOString(),
|
|
165
|
+
status: 'unknown',
|
|
166
|
+
fee_sats: plan.feeSats,
|
|
167
|
+
decision_id: auth.decisionId,
|
|
168
|
+
});
|
|
169
|
+
addSessionSpent(network, plan.amountSats); // may have moved: count it
|
|
170
|
+
throw new CliError(EXIT.BROADCAST_UNKNOWN, 'broadcast_status_unknown', `Broadcast status unknown (network failure mid-send). txid ${txid} — check ${explorerTxUrl(network, txid)} before retrying; the funds may already have moved.`, { txid, explorer_url: explorerTxUrl(network, txid) });
|
|
171
|
+
}
|
|
172
|
+
appendLedger(network, {
|
|
173
|
+
type: 'send',
|
|
174
|
+
txid,
|
|
175
|
+
amount_sats: plan.amountSats,
|
|
176
|
+
address: plan.to,
|
|
177
|
+
memo: plan.memo,
|
|
178
|
+
timestamp: new Date().toISOString(),
|
|
179
|
+
status: 'pending',
|
|
180
|
+
fee_sats: plan.feeSats,
|
|
181
|
+
decision_id: auth.decisionId,
|
|
182
|
+
});
|
|
183
|
+
addSessionSpent(network, plan.amountSats);
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* BRC-100 custody: the gate has already authorized this exact spend; the
|
|
188
|
+
* external wallet now funds, signs, and broadcasts it in one createAction
|
|
189
|
+
* (so there is no separate broadcast site — the choke-point scan covers
|
|
190
|
+
* payToAddress instead). Ledger semantics mirror the local path: success
|
|
191
|
+
* appends `pending`, an ambiguous wallet-side outcome (exit 6) appends
|
|
192
|
+
* `unknown` and counts against the session budget, a definite refusal
|
|
193
|
+
* appends nothing. Dry runs return the estimated plan without touching
|
|
194
|
+
* the external wallet — there is no txid to show until it signs.
|
|
195
|
+
*/
|
|
196
|
+
async function executeBrc100Send(brc100, network, plan, decisionId, dryRun) {
|
|
197
|
+
if (dryRun) {
|
|
198
|
+
return {
|
|
199
|
+
txid: '',
|
|
200
|
+
to: plan.to,
|
|
201
|
+
amountSats: plan.amountSats,
|
|
202
|
+
feeSats: plan.feeSats,
|
|
203
|
+
changeSats: 0,
|
|
204
|
+
balanceAfterSats: plan.balanceAfterSats,
|
|
205
|
+
sizeBytes: 0,
|
|
206
|
+
dryRun: true,
|
|
207
|
+
explorerUrl: '',
|
|
208
|
+
rawTxHex: '',
|
|
209
|
+
external: true,
|
|
210
|
+
feeEstimated: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
let paid;
|
|
214
|
+
try {
|
|
215
|
+
paid = await brc100.payToAddress({ to: plan.to, amountSats: plan.amountSats, memo: plan.memo });
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
if (e instanceof CliError && e.exitCode === EXIT.BROADCAST_UNKNOWN) {
|
|
219
|
+
// The money may have moved (mirrors the local ambiguous-broadcast path).
|
|
220
|
+
appendLedger(network, {
|
|
221
|
+
type: 'send',
|
|
222
|
+
txid: typeof e.data?.txid === 'string' ? e.data.txid : '',
|
|
223
|
+
amount_sats: plan.amountSats,
|
|
224
|
+
address: plan.to,
|
|
225
|
+
memo: plan.memo,
|
|
226
|
+
timestamp: new Date().toISOString(),
|
|
227
|
+
status: 'unknown',
|
|
228
|
+
decision_id: decisionId,
|
|
229
|
+
});
|
|
230
|
+
addSessionSpent(network, plan.amountSats);
|
|
231
|
+
}
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
appendLedger(network, {
|
|
235
|
+
type: 'send',
|
|
236
|
+
txid: paid.txid,
|
|
237
|
+
amount_sats: plan.amountSats,
|
|
238
|
+
address: plan.to,
|
|
239
|
+
memo: plan.memo,
|
|
240
|
+
timestamp: new Date().toISOString(),
|
|
241
|
+
status: 'pending',
|
|
242
|
+
fee_sats: paid.feeSats,
|
|
243
|
+
decision_id: decisionId,
|
|
244
|
+
});
|
|
245
|
+
addSessionSpent(network, plan.amountSats);
|
|
246
|
+
const feeKnown = paid.feeSats !== undefined;
|
|
247
|
+
const feeSats = paid.feeSats ?? plan.feeSats;
|
|
248
|
+
return {
|
|
249
|
+
txid: paid.txid,
|
|
250
|
+
to: plan.to,
|
|
251
|
+
amountSats: plan.amountSats,
|
|
252
|
+
feeSats,
|
|
253
|
+
changeSats: paid.changeSats,
|
|
254
|
+
// plan.balanceAfterSats was computed with the estimated fee; swap in the real one.
|
|
255
|
+
balanceAfterSats: plan.balanceAfterSats + plan.feeSats - feeSats,
|
|
256
|
+
sizeBytes: paid.sizeBytes,
|
|
257
|
+
dryRun: false,
|
|
258
|
+
explorerUrl: explorerTxUrl(network, paid.txid),
|
|
259
|
+
rawTxHex: paid.rawTxHex,
|
|
260
|
+
external: true,
|
|
261
|
+
...(feeKnown ? {} : { feeEstimated: true }),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Plan and execute in one call. Never prompts; the policy gate applies.
|
|
266
|
+
* Single-flight per state dir + network: concurrent send() calls in one
|
|
267
|
+
* process (an MCP server, a library embedder) are serialized across the
|
|
268
|
+
* whole decide→broadcast→ledger span, so a later spend is decided only
|
|
269
|
+
* after every earlier one has hit the ledger — two simultaneous spends can
|
|
270
|
+
* never both pass the same remaining budget.
|
|
271
|
+
*/
|
|
272
|
+
export async function send(wallet, opts, params) {
|
|
273
|
+
return withSpendLock(opts.network, async () => {
|
|
274
|
+
const plan = await planSend(wallet, opts, params);
|
|
275
|
+
return executeSend(wallet, opts, plan, { dryRun: params.dryRun });
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { baseDir } from '../paths.js';
|
|
2
|
+
/**
|
|
3
|
+
* Single-flight for the spend critical section (decide → sign → broadcast →
|
|
4
|
+
* ledger). Budgets and rate limits are recomputed from the ledger when the
|
|
5
|
+
* policy gate decides, but the send entry that consumes them is appended
|
|
6
|
+
* only after broadcast — so two spends in flight at once could BOTH pass a
|
|
7
|
+
* budget check before either is recorded. Any long-running process that can
|
|
8
|
+
* receive concurrent spend calls (the MCP server, library embedders using
|
|
9
|
+
* send()) must serialize the whole span; a CLI invocation is one spend per
|
|
10
|
+
* process, where the lock is a no-op.
|
|
11
|
+
*
|
|
12
|
+
* Keyed by state dir + network, matching how budgets are scoped. A spend
|
|
13
|
+
* that throws (denied, insufficient funds, broadcast failure) releases the
|
|
14
|
+
* lock; the next spend in line re-reads usage from the ledger and is decided
|
|
15
|
+
* on the true state. Deliberately module-private to core: not exported from
|
|
16
|
+
* bsv-pay/core, no timeout, no reentrancy.
|
|
17
|
+
*/
|
|
18
|
+
const tails = new Map();
|
|
19
|
+
export function withSpendLock(network, fn) {
|
|
20
|
+
const key = `${baseDir()}::${network}`;
|
|
21
|
+
const prev = tails.get(key) ?? Promise.resolve();
|
|
22
|
+
const run = prev.then(fn);
|
|
23
|
+
tails.set(key, run.then(() => undefined, () => undefined));
|
|
24
|
+
return run;
|
|
25
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Network } from '../paths.js';
|
|
2
|
+
import { type Brc100Interface } from '../wallet/brc100.js';
|
|
3
|
+
import type { CoreOptions } from './context.js';
|
|
4
|
+
export interface OpenWalletOptions extends CoreOptions {
|
|
5
|
+
/** Passphrase or async supplier; default: BSV_PAY_PASSPHRASE env. Never prompts. */
|
|
6
|
+
passphrase?: string | (() => Promise<string>);
|
|
7
|
+
/** Receives human warnings (e.g. unencrypted wallet); default: collected on `warnings`. */
|
|
8
|
+
onWarning?: (text: string) => void;
|
|
9
|
+
}
|
|
10
|
+
/** Which custody backend an open wallet delegates to. */
|
|
11
|
+
export type WalletBackendKind = 'local' | 'brc100';
|
|
12
|
+
/**
|
|
13
|
+
* An open wallet as seen through the library boundary: addresses and
|
|
14
|
+
* metadata only. Signing keys (and, for BRC-100 custody, the external
|
|
15
|
+
* wallet handle — key-capable by proxy) stay inside src/wallet/
|
|
16
|
+
* (invariant 1) — core modules reach the signing backend through a private
|
|
17
|
+
* registry that is not exported from the public entrypoint, never through
|
|
18
|
+
* any member of this class.
|
|
19
|
+
*/
|
|
20
|
+
export declare class CoreWallet {
|
|
21
|
+
readonly network: Network;
|
|
22
|
+
/** False for single-address WIF wallets. */
|
|
23
|
+
readonly isHd: boolean;
|
|
24
|
+
/** Warnings raised during unlock when no onWarning sink was supplied. */
|
|
25
|
+
readonly warnings: readonly string[];
|
|
26
|
+
/** Custody backend (additive, M12): local seed or external BRC-100 wallet. */
|
|
27
|
+
readonly backend: WalletBackendKind;
|
|
28
|
+
constructor(network: Network,
|
|
29
|
+
/** False for single-address WIF wallets. */
|
|
30
|
+
isHd: boolean,
|
|
31
|
+
/** Warnings raised during unlock when no onWarning sink was supplied. */
|
|
32
|
+
warnings: readonly string[],
|
|
33
|
+
/** Custody backend (additive, M12): local seed or external BRC-100 wallet. */
|
|
34
|
+
backend?: WalletBackendKind);
|
|
35
|
+
/**
|
|
36
|
+
* Every address this wallet has issued (receive + change chains). A
|
|
37
|
+
* BRC-100 wallet manages its own addresses internally: empty array.
|
|
38
|
+
*/
|
|
39
|
+
addresses(): string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Unlock a wallet for library use. Unlike the CLI, this never prompts and
|
|
43
|
+
* never writes to stdout/stderr: the passphrase comes from the explicit
|
|
44
|
+
* option or BSV_PAY_PASSPHRASE; an encrypted wallet with neither throws
|
|
45
|
+
* code 7 (`passphrase_required`). A missing wallet file throws code 2
|
|
46
|
+
* (`no_wallet`).
|
|
47
|
+
*
|
|
48
|
+
* When the wallet file delegates to BRC-100 custody (EXPERIMENTAL), this
|
|
49
|
+
* connects to the external wallet app instead — no passphrase involved;
|
|
50
|
+
* an unreachable wallet app throws code 7 (`brc100_unreachable`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function openWallet(opts: OpenWalletOptions): Promise<CoreWallet>;
|
|
53
|
+
export type { Brc100Interface };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { CliError, EXIT } from '../errors.js';
|
|
2
|
+
import { connectBrc100 } from '../wallet/brc100.js';
|
|
3
|
+
import { readWalletFile, Wallet } from '../wallet/wallet.js';
|
|
4
|
+
import { registerWallet, unwrapBackend, unwrapWallet } from './internal.js';
|
|
5
|
+
/**
|
|
6
|
+
* An open wallet as seen through the library boundary: addresses and
|
|
7
|
+
* metadata only. Signing keys (and, for BRC-100 custody, the external
|
|
8
|
+
* wallet handle — key-capable by proxy) stay inside src/wallet/
|
|
9
|
+
* (invariant 1) — core modules reach the signing backend through a private
|
|
10
|
+
* registry that is not exported from the public entrypoint, never through
|
|
11
|
+
* any member of this class.
|
|
12
|
+
*/
|
|
13
|
+
export class CoreWallet {
|
|
14
|
+
network;
|
|
15
|
+
isHd;
|
|
16
|
+
warnings;
|
|
17
|
+
backend;
|
|
18
|
+
constructor(network,
|
|
19
|
+
/** False for single-address WIF wallets. */
|
|
20
|
+
isHd,
|
|
21
|
+
/** Warnings raised during unlock when no onWarning sink was supplied. */
|
|
22
|
+
warnings,
|
|
23
|
+
/** Custody backend (additive, M12): local seed or external BRC-100 wallet. */
|
|
24
|
+
backend = 'local') {
|
|
25
|
+
this.network = network;
|
|
26
|
+
this.isHd = isHd;
|
|
27
|
+
this.warnings = warnings;
|
|
28
|
+
this.backend = backend;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every address this wallet has issued (receive + change chains). A
|
|
32
|
+
* BRC-100 wallet manages its own addresses internally: empty array.
|
|
33
|
+
*/
|
|
34
|
+
addresses() {
|
|
35
|
+
if (unwrapBackend(this).kind === 'brc100')
|
|
36
|
+
return [];
|
|
37
|
+
return unwrapWallet(this)
|
|
38
|
+
.trackedAddresses()
|
|
39
|
+
.map((a) => a.address);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Unlock a wallet for library use. Unlike the CLI, this never prompts and
|
|
44
|
+
* never writes to stdout/stderr: the passphrase comes from the explicit
|
|
45
|
+
* option or BSV_PAY_PASSPHRASE; an encrypted wallet with neither throws
|
|
46
|
+
* code 7 (`passphrase_required`). A missing wallet file throws code 2
|
|
47
|
+
* (`no_wallet`).
|
|
48
|
+
*
|
|
49
|
+
* When the wallet file delegates to BRC-100 custody (EXPERIMENTAL), this
|
|
50
|
+
* connects to the external wallet app instead — no passphrase involved;
|
|
51
|
+
* an unreachable wallet app throws code 7 (`brc100_unreachable`).
|
|
52
|
+
*/
|
|
53
|
+
export async function openWallet(opts) {
|
|
54
|
+
const file = readWalletFile(opts.network);
|
|
55
|
+
if (file.backend === 'brc100') {
|
|
56
|
+
const brc100 = await connectBrc100(opts.network, {
|
|
57
|
+
url: file.brc100_url,
|
|
58
|
+
wallet: opts.brc100,
|
|
59
|
+
});
|
|
60
|
+
const pub = new CoreWallet(opts.network, true, [], 'brc100');
|
|
61
|
+
registerWallet(pub, { kind: 'brc100', wallet: brc100 });
|
|
62
|
+
return pub;
|
|
63
|
+
}
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const onWarning = opts.onWarning ?? ((text) => warnings.push(text));
|
|
66
|
+
// Only consulted for encrypted wallets, so unencrypted wallets open
|
|
67
|
+
// without any passphrase — same as the CLI.
|
|
68
|
+
const passphrase = opts.passphrase ??
|
|
69
|
+
process.env.BSV_PAY_PASSPHRASE ??
|
|
70
|
+
(() => {
|
|
71
|
+
throw new CliError(EXIT.WALLET_LOCKED, 'passphrase_required', 'Wallet is encrypted. Pass `passphrase` to openWallet() or set BSV_PAY_PASSPHRASE.');
|
|
72
|
+
});
|
|
73
|
+
const wallet = await Wallet.unlock(opts.network, { passphrase, onWarning });
|
|
74
|
+
const pub = new CoreWallet(opts.network, wallet.isHd, warnings, 'local');
|
|
75
|
+
registerWallet(pub, { kind: 'local', wallet });
|
|
76
|
+
return pub;
|
|
77
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Stable exit codes, documented in README. */
|
|
2
|
+
export declare const EXIT: {
|
|
3
|
+
readonly OK: 0;
|
|
4
|
+
readonly UNEXPECTED: 1;
|
|
5
|
+
readonly USAGE: 2;
|
|
6
|
+
readonly INSUFFICIENT_FUNDS: 3;
|
|
7
|
+
readonly NETWORK: 4;
|
|
8
|
+
readonly BROADCAST_REJECTED: 5;
|
|
9
|
+
readonly BROADCAST_UNKNOWN: 6;
|
|
10
|
+
readonly WALLET_LOCKED: 7;
|
|
11
|
+
readonly SPEND_LIMIT: 8;
|
|
12
|
+
/** Phase 2: the spend was queued for human approval instead of sent. */
|
|
13
|
+
readonly PENDING_APPROVAL: 9;
|
|
14
|
+
/** Phase 2: a 402 payment broadcast but the server refused the content. */
|
|
15
|
+
readonly PAYMENT_NOT_REDEEMED: 10;
|
|
16
|
+
};
|
|
17
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
18
|
+
/**
|
|
19
|
+
* Error carrying a stable exit code and a snake_case machine-readable code
|
|
20
|
+
* for --json output. `data` is merged into the JSON error object (never
|
|
21
|
+
* include key material).
|
|
22
|
+
*/
|
|
23
|
+
export declare class CliError extends Error {
|
|
24
|
+
readonly exitCode: ExitCode;
|
|
25
|
+
readonly errorCode: string;
|
|
26
|
+
readonly data?: Record<string, unknown> | undefined;
|
|
27
|
+
constructor(exitCode: ExitCode, errorCode: string, message: string, data?: Record<string, unknown> | undefined);
|
|
28
|
+
}
|
|
29
|
+
export declare function usageError(errorCode: string, message: string): CliError;
|
|
30
|
+
export declare function networkError(message: string, data?: Record<string, unknown>): CliError;
|