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/errors.js ADDED
@@ -0,0 +1,39 @@
1
+ /** Stable exit codes, documented in README. */
2
+ export const EXIT = {
3
+ OK: 0,
4
+ UNEXPECTED: 1,
5
+ USAGE: 2,
6
+ INSUFFICIENT_FUNDS: 3,
7
+ NETWORK: 4,
8
+ BROADCAST_REJECTED: 5,
9
+ BROADCAST_UNKNOWN: 6,
10
+ WALLET_LOCKED: 7,
11
+ SPEND_LIMIT: 8,
12
+ /** Phase 2: the spend was queued for human approval instead of sent. */
13
+ PENDING_APPROVAL: 9,
14
+ /** Phase 2: a 402 payment broadcast but the server refused the content. */
15
+ PAYMENT_NOT_REDEEMED: 10,
16
+ };
17
+ /**
18
+ * Error carrying a stable exit code and a snake_case machine-readable code
19
+ * for --json output. `data` is merged into the JSON error object (never
20
+ * include key material).
21
+ */
22
+ export class CliError extends Error {
23
+ exitCode;
24
+ errorCode;
25
+ data;
26
+ constructor(exitCode, errorCode, message, data) {
27
+ super(message);
28
+ this.exitCode = exitCode;
29
+ this.errorCode = errorCode;
30
+ this.data = data;
31
+ this.name = 'CliError';
32
+ }
33
+ }
34
+ export function usageError(errorCode, message) {
35
+ return new CliError(EXIT.USAGE, errorCode, message);
36
+ }
37
+ export function networkError(message, data) {
38
+ return new CliError(EXIT.NETWORK, 'network_error', message, data);
39
+ }
@@ -0,0 +1,32 @@
1
+ import type { CoreOptions } from '../core/context.js';
2
+ import type { CoreWallet } from '../core/wallet.js';
3
+ export interface PaidFetchParams {
4
+ url: string;
5
+ /** Hard cap for THIS fetch, checked before any spend. Exit 8 when exceeded. */
6
+ maxPriceSats?: number;
7
+ }
8
+ export interface PaidFetchPayment {
9
+ txid: string;
10
+ amountSats: number;
11
+ feeSats: number;
12
+ address: string;
13
+ derivationPrefix: string;
14
+ }
15
+ export interface PaidFetchResult {
16
+ /** HTTP status of the final response (after payment, if one was made). */
17
+ status: number;
18
+ body: string;
19
+ contentType?: string;
20
+ /** False when the resource came back without requiring payment. */
21
+ paid: boolean;
22
+ payment?: PaidFetchPayment;
23
+ }
24
+ /**
25
+ * GET a URL, automatically paying a BRC-105 402 challenge within policy.
26
+ * Free resources return with `paid: false` and no spend. Throws exit 8 on
27
+ * `max_price_exceeded` (before the gate) or any policy denial (from the
28
+ * gate, ledgered), exit 9 when the payment queued for human approval, and
29
+ * exit 10 `payment_not_redeemed` when the payment broadcast but the server
30
+ * still refused the content (the txid is in the error data — money moved).
31
+ */
32
+ export declare function paidFetch(wallet: CoreWallet, opts: CoreOptions, params: PaidFetchParams): Promise<PaidFetchResult>;
@@ -0,0 +1,85 @@
1
+ import { CliError, EXIT, networkError } from '../errors.js';
2
+ import { send } from '../core/send.js';
3
+ import { encodePaymentEnvelope, parsePaymentTerms, HEADER } from './protocol.js';
4
+ /**
5
+ * The paying side of the 402 flow (BRC-105 simplified profile — see
6
+ * src/http402/protocol.ts and DECISIONS.md M11). The payment itself goes
7
+ * through core send(): the policy gate decides and ledgers it, the
8
+ * single-flight lock serializes it, and `maxPriceSats` additionally caps
9
+ * this one fetch regardless of policy headroom. This module is the ONLY
10
+ * core file allowed to call fetch() besides the chain provider — it can
11
+ * talk HTTP, but it cannot sign or broadcast anything itself.
12
+ */
13
+ const REQUEST_TIMEOUT_MS = 30_000;
14
+ const MEMO_MAX_CHARS = 120;
15
+ async function httpGet(url, headers) {
16
+ try {
17
+ return await fetch(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
18
+ }
19
+ catch (e) {
20
+ throw networkError(`Cannot reach ${url}: ${e instanceof Error ? e.message : String(e)}.`, {
21
+ url,
22
+ });
23
+ }
24
+ }
25
+ /**
26
+ * GET a URL, automatically paying a BRC-105 402 challenge within policy.
27
+ * Free resources return with `paid: false` and no spend. Throws exit 8 on
28
+ * `max_price_exceeded` (before the gate) or any policy denial (from the
29
+ * gate, ledgered), exit 9 when the payment queued for human approval, and
30
+ * exit 10 `payment_not_redeemed` when the payment broadcast but the server
31
+ * still refused the content (the txid is in the error data — money moved).
32
+ */
33
+ export async function paidFetch(wallet, opts, params) {
34
+ if (!/^https?:\/\//i.test(params.url)) {
35
+ throw new CliError(EXIT.USAGE, 'invalid_url', `fetch needs an http(s) URL (got "${params.url}").`);
36
+ }
37
+ const first = await httpGet(params.url);
38
+ if (first.status !== 402) {
39
+ return {
40
+ status: first.status,
41
+ body: await first.text(),
42
+ contentType: first.headers.get('content-type') ?? undefined,
43
+ paid: false,
44
+ };
45
+ }
46
+ const terms = parsePaymentTerms(first.headers, opts.network);
47
+ if (params.maxPriceSats !== undefined && terms.satoshisRequired > params.maxPriceSats) {
48
+ throw new CliError(EXIT.SPEND_LIMIT, 'max_price_exceeded', `Server asks ${terms.satoshisRequired} sats but --max-price caps this fetch at ${params.maxPriceSats} sats. Nothing was paid.`, { url: params.url, price_sats: terms.satoshisRequired, max_price_sats: params.maxPriceSats });
49
+ }
50
+ // The actual spend: policy gate, single-flight lock, ledger — all in core.
51
+ const payment = await send(wallet, opts, {
52
+ to: terms.address,
53
+ amountSats: terms.satoshisRequired,
54
+ memo: `402 ${params.url}`.slice(0, MEMO_MAX_CHARS),
55
+ });
56
+ const retry = await httpGet(params.url, {
57
+ [HEADER.payment]: encodePaymentEnvelope({
58
+ derivationPrefix: terms.derivationPrefix,
59
+ txid: payment.txid,
60
+ transaction: payment.rawTxHex,
61
+ }),
62
+ });
63
+ if (!retry.ok) {
64
+ throw new CliError(EXIT.PAYMENT_NOT_REDEEMED, 'payment_not_redeemed', `Paid ${terms.satoshisRequired} sats (txid ${payment.txid}) but the server still responded ${retry.status}. The payment is on-chain and in your ledger; take it up with the seller.`, {
65
+ url: params.url,
66
+ status: retry.status,
67
+ txid: payment.txid,
68
+ amount_sats: terms.satoshisRequired,
69
+ address: terms.address,
70
+ });
71
+ }
72
+ return {
73
+ status: retry.status,
74
+ body: await retry.text(),
75
+ contentType: retry.headers.get('content-type') ?? undefined,
76
+ paid: true,
77
+ payment: {
78
+ txid: payment.txid,
79
+ amountSats: payment.amountSats,
80
+ feeSats: payment.feeSats,
81
+ address: terms.address,
82
+ derivationPrefix: terms.derivationPrefix,
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,37 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { CoreOptions } from '../core/context.js';
3
+ import type { CoreWallet } from '../core/wallet.js';
4
+ /**
5
+ * The selling side of the 402 flow (BRC-105 simplified profile — see
6
+ * src/http402/protocol.ts). Express-compatible `(req, res, next)` handler
7
+ * with zero framework dependencies: it types req/res structurally against
8
+ * node's http primitives, so it drops into Express, plain node:http
9
+ * (`bsv-pay serve`), or anything shaped like them.
10
+ *
11
+ * Money flow: every quote issues a fresh wallet address (the bsv-pay
12
+ * `request` model — unambiguous matching, funds land tracked and
13
+ * spendable). The buyer broadcasts; this side only READS its chain view
14
+ * to confirm (awaitPayment, which also ledgers the receive, invariant 6).
15
+ * No signing, no broadcasting, no fetch — the choke points are untouched.
16
+ */
17
+ export interface RequirePaymentOptions extends CoreOptions {
18
+ wallet: CoreWallet;
19
+ /** Price per paid request, in satoshis. */
20
+ priceSats: number;
21
+ /** How long a quoted prefix stays payable (default 10 minutes). */
22
+ quoteTtlMs?: number;
23
+ /** How long to wait for the presented payment on-chain (default 20 s). */
24
+ confirmTimeoutMs?: number;
25
+ }
26
+ export interface PaymentReceipt {
27
+ txid: string;
28
+ amountSats: number;
29
+ address: string;
30
+ derivationPrefix: string;
31
+ }
32
+ /** A request that passed the paywall carries its receipt. */
33
+ export type PaidRequest = IncomingMessage & {
34
+ bsvPayment?: PaymentReceipt;
35
+ };
36
+ export type PaidRequestHandler = (req: PaidRequest, res: ServerResponse, next?: () => void) => Promise<void>;
37
+ export declare function requirePayment(opts: RequirePaymentOptions): PaidRequestHandler;
@@ -0,0 +1,96 @@
1
+ import crypto from 'node:crypto';
2
+ import { awaitPayment, createRequest } from '../core/request.js';
3
+ import { brc100ReceiveNotSupported } from '../wallet/brc100.js';
4
+ import { buildTermsHeaders, parsePaymentEnvelope, transactionPays, HEADER } from './protocol.js';
5
+ export function requirePayment(opts) {
6
+ // Selling means issuing receive addresses, which BRC-100 custody cannot do
7
+ // (the wallet app would never see the funds). Fail at construction, not on
8
+ // the first customer.
9
+ if (opts.wallet.backend === 'brc100')
10
+ throw brc100ReceiveNotSupported();
11
+ const quoteTtlMs = opts.quoteTtlMs ?? 600_000;
12
+ const confirmTimeoutMs = opts.confirmTimeoutMs ?? 20_000;
13
+ const core = {
14
+ network: opts.network,
15
+ config: opts.config,
16
+ provider: opts.provider,
17
+ };
18
+ /** prefix → quote. In-memory and single-use: a restart simply re-quotes. */
19
+ const quotes = new Map();
20
+ function respond402(res, error) {
21
+ const now = Date.now();
22
+ for (const [prefix, quote] of quotes) {
23
+ if (quote.used || quote.expiresAt < now)
24
+ quotes.delete(prefix);
25
+ }
26
+ const derivationPrefix = crypto.randomBytes(16).toString('base64');
27
+ const request = createRequest(opts.wallet, {
28
+ amountSats: opts.priceSats,
29
+ memo: '402 quote',
30
+ });
31
+ quotes.set(derivationPrefix, {
32
+ address: request.address,
33
+ satoshisRequired: opts.priceSats,
34
+ expiresAt: now + quoteTtlMs,
35
+ used: false,
36
+ });
37
+ res.writeHead(402, {
38
+ 'content-type': 'application/json',
39
+ ...buildTermsHeaders({
40
+ satoshisRequired: opts.priceSats,
41
+ derivationPrefix,
42
+ address: request.address,
43
+ }),
44
+ ...(error ? { [HEADER.error]: error } : {}),
45
+ });
46
+ res.end(JSON.stringify({
47
+ ok: false,
48
+ error: error ?? 'payment_required',
49
+ satoshis_required: opts.priceSats,
50
+ }));
51
+ }
52
+ return async (req, res, next) => {
53
+ const envelope = parsePaymentEnvelope(req.headers[HEADER.payment]);
54
+ if (!envelope) {
55
+ respond402(res);
56
+ return;
57
+ }
58
+ const quote = quotes.get(envelope.derivationPrefix);
59
+ if (!quote || quote.used || quote.expiresAt < Date.now()) {
60
+ respond402(res, 'unknown_or_expired_prefix');
61
+ return;
62
+ }
63
+ // Structural pre-check on the presented hex (public data) before
64
+ // touching the chain: does it even pay this quote?
65
+ if (!transactionPays(envelope.transaction, quote.address, quote.satoshisRequired, opts.network)) {
66
+ respond402(res, 'payment_insufficient');
67
+ return;
68
+ }
69
+ // Claim the quote BEFORE confirming so two concurrent retries with the
70
+ // same prefix can never both redeem (released again if not found).
71
+ quote.used = true;
72
+ let payment;
73
+ try {
74
+ payment = await awaitPayment(core, {
75
+ address: quote.address,
76
+ timeoutMs: confirmTimeoutMs,
77
+ memo: `402 sale ${req.url ?? ''}`.trim(),
78
+ });
79
+ }
80
+ catch {
81
+ quote.used = false; // still payable until the TTL
82
+ respond402(res, 'payment_not_found');
83
+ return;
84
+ }
85
+ const receipt = {
86
+ txid: payment.txid,
87
+ amountSats: payment.receivedSats,
88
+ address: quote.address,
89
+ derivationPrefix: envelope.derivationPrefix,
90
+ };
91
+ req.bsvPayment = receipt;
92
+ res.setHeader(HEADER.satoshisPaid, String(payment.receivedSats));
93
+ if (next)
94
+ next();
95
+ };
96
+ }
@@ -0,0 +1,50 @@
1
+ import type { Network } from '../paths.js';
2
+ /**
3
+ * The BRC-105 header exchange, simplified profile (see DECISIONS.md M11):
4
+ * same header names, flow, and version as the spec, with two documented
5
+ * divergences until M12 brings BRC-100 custody — the payment destination
6
+ * is a fresh server-wallet address advertised via `x-bsv-payment-address`
7
+ * (instead of BRC-29 key derivation), and `transaction` in the retry
8
+ * envelope is raw tx hex (instead of AtomicBEEF).
9
+ */
10
+ export declare const PAYMENT_VERSION = "1.0";
11
+ export declare const HEADER: {
12
+ readonly version: "x-bsv-payment-version";
13
+ readonly satoshisRequired: "x-bsv-payment-satoshis-required";
14
+ readonly derivationPrefix: "x-bsv-payment-derivation-prefix";
15
+ readonly address: "x-bsv-payment-address";
16
+ readonly payment: "x-bsv-payment";
17
+ readonly satoshisPaid: "x-bsv-payment-satoshis-paid";
18
+ readonly error: "x-bsv-payment-error";
19
+ };
20
+ /** What a 402 response demands. */
21
+ export interface PaymentTerms {
22
+ satoshisRequired: number;
23
+ derivationPrefix: string;
24
+ address: string;
25
+ }
26
+ /** What the client presents on retry (the `x-bsv-payment` header, JSON). */
27
+ export interface PaymentEnvelope {
28
+ derivationPrefix: string;
29
+ txid: string;
30
+ /** Signed raw transaction hex — public data, already broadcast by the payer. */
31
+ transaction: string;
32
+ }
33
+ /**
34
+ * Parse and validate the payment terms on a 402 response. Throws exit 4
35
+ * without spending anything when the terms are unusable; per BRC-105, a
36
+ * version we do not support means we MUST NOT pay.
37
+ */
38
+ export declare function parsePaymentTerms(headers: {
39
+ get(name: string): string | null;
40
+ }, network: Network): PaymentTerms;
41
+ export declare function buildTermsHeaders(terms: PaymentTerms): Record<string, string>;
42
+ export declare function encodePaymentEnvelope(envelope: PaymentEnvelope): string;
43
+ /** Parse the retry envelope; null means "treat as an unpaid request". */
44
+ export declare function parsePaymentEnvelope(raw: string | undefined | null): PaymentEnvelope | null;
45
+ /**
46
+ * Structural check of a presented payment: does this transaction pay at
47
+ * least `satoshisRequired` to `address`? Read-only over public data — the
48
+ * authoritative confirmation is the seller's own chain view.
49
+ */
50
+ export declare function transactionPays(rawTxHex: string, address: string, satoshisRequired: number, network: Network): boolean;
@@ -0,0 +1,114 @@
1
+ import { Transaction, Utils } from '@bsv/sdk';
2
+ import { validateAddress } from '../address.js';
3
+ import { CliError, EXIT } from '../errors.js';
4
+ /**
5
+ * The BRC-105 header exchange, simplified profile (see DECISIONS.md M11):
6
+ * same header names, flow, and version as the spec, with two documented
7
+ * divergences until M12 brings BRC-100 custody — the payment destination
8
+ * is a fresh server-wallet address advertised via `x-bsv-payment-address`
9
+ * (instead of BRC-29 key derivation), and `transaction` in the retry
10
+ * envelope is raw tx hex (instead of AtomicBEEF).
11
+ */
12
+ export const PAYMENT_VERSION = '1.0';
13
+ export const HEADER = {
14
+ version: 'x-bsv-payment-version',
15
+ satoshisRequired: 'x-bsv-payment-satoshis-required',
16
+ derivationPrefix: 'x-bsv-payment-derivation-prefix',
17
+ address: 'x-bsv-payment-address',
18
+ payment: 'x-bsv-payment',
19
+ satoshisPaid: 'x-bsv-payment-satoshis-paid',
20
+ error: 'x-bsv-payment-error',
21
+ };
22
+ function badTerms(message) {
23
+ // The server sent a 402 we must not pay: a remote-protocol failure
24
+ // (exit 4), and crucially BEFORE any spend was attempted.
25
+ throw new CliError(EXIT.NETWORK, 'invalid_payment_terms', message);
26
+ }
27
+ /**
28
+ * Parse and validate the payment terms on a 402 response. Throws exit 4
29
+ * without spending anything when the terms are unusable; per BRC-105, a
30
+ * version we do not support means we MUST NOT pay.
31
+ */
32
+ export function parsePaymentTerms(headers, network) {
33
+ const version = headers.get(HEADER.version);
34
+ if (version !== PAYMENT_VERSION) {
35
+ badTerms(`Server requested payment version "${version ?? '(missing)'}" but this client supports ${PAYMENT_VERSION}. Not paying.`);
36
+ }
37
+ const satoshisRequired = Number(headers.get(HEADER.satoshisRequired));
38
+ if (!Number.isSafeInteger(satoshisRequired) || satoshisRequired <= 0) {
39
+ badTerms('Missing or invalid x-bsv-payment-satoshis-required header. Not paying.');
40
+ }
41
+ const derivationPrefix = headers.get(HEADER.derivationPrefix);
42
+ if (typeof derivationPrefix !== 'string' || derivationPrefix.length < 1) {
43
+ badTerms('Missing x-bsv-payment-derivation-prefix header. Not paying.');
44
+ }
45
+ const address = headers.get(HEADER.address);
46
+ if (typeof address !== 'string' || address.length < 1) {
47
+ badTerms('Missing x-bsv-payment-address header. Not paying.');
48
+ }
49
+ try {
50
+ validateAddress(address, network); // wrong-network terms are never payable
51
+ }
52
+ catch {
53
+ badTerms(`Payment address "${address}" is not valid for this network. Not paying.`);
54
+ }
55
+ return { satoshisRequired, derivationPrefix, address };
56
+ }
57
+ export function buildTermsHeaders(terms) {
58
+ return {
59
+ [HEADER.version]: PAYMENT_VERSION,
60
+ [HEADER.satoshisRequired]: String(terms.satoshisRequired),
61
+ [HEADER.derivationPrefix]: terms.derivationPrefix,
62
+ [HEADER.address]: terms.address,
63
+ };
64
+ }
65
+ export function encodePaymentEnvelope(envelope) {
66
+ return JSON.stringify(envelope);
67
+ }
68
+ /** Parse the retry envelope; null means "treat as an unpaid request". */
69
+ export function parsePaymentEnvelope(raw) {
70
+ if (!raw)
71
+ return null;
72
+ try {
73
+ const doc = JSON.parse(raw);
74
+ if (typeof doc.derivationPrefix === 'string' &&
75
+ typeof doc.txid === 'string' &&
76
+ typeof doc.transaction === 'string') {
77
+ return {
78
+ derivationPrefix: doc.derivationPrefix,
79
+ txid: doc.txid,
80
+ transaction: doc.transaction,
81
+ };
82
+ }
83
+ }
84
+ catch {
85
+ // malformed JSON falls through to null
86
+ }
87
+ return null;
88
+ }
89
+ const P2PKH_PREFIX = { main: 0x00, test: 0x6f };
90
+ /**
91
+ * Structural check of a presented payment: does this transaction pay at
92
+ * least `satoshisRequired` to `address`? Read-only over public data — the
93
+ * authoritative confirmation is the seller's own chain view.
94
+ */
95
+ export function transactionPays(rawTxHex, address, satoshisRequired, network) {
96
+ let tx;
97
+ try {
98
+ tx = Transaction.fromHex(rawTxHex);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ let paid = 0;
104
+ for (const out of tx.outputs) {
105
+ const m = /^76a914([0-9a-f]{40})88ac$/.exec(out.lockingScript.toHex());
106
+ if (!m)
107
+ continue;
108
+ const bytes = m[1].match(/../g).map((b) => parseInt(b, 16));
109
+ if (Utils.toBase58Check(bytes, [P2PKH_PREFIX[network]]) === address) {
110
+ paid += out.satoshis ?? 0;
111
+ }
112
+ }
113
+ return paid >= satoshisRequired;
114
+ }
@@ -0,0 +1,51 @@
1
+ import { type Network } from './paths.js';
2
+ /**
3
+ * Append-only local ledger (~/.bsv-pay/ledger.jsonl). Records sends,
4
+ * receives, issued addresses, and (Phase 2) every policy decision and
5
+ * approval resolution. Memos are local-only — never on-chain.
6
+ * Never contains key material. Entry types are additive-only.
7
+ */
8
+ export type LedgerEntry = {
9
+ type: 'send' | 'receive';
10
+ txid: string;
11
+ amount_sats: number;
12
+ address: string;
13
+ memo?: string;
14
+ timestamp: string;
15
+ status: 'pending' | 'confirmed' | 'unknown';
16
+ fee_sats?: number;
17
+ /** Links a send to the policy decision that authorized it (Phase 2). */
18
+ decision_id?: string;
19
+ } | {
20
+ type: 'address_issued';
21
+ address: string;
22
+ derivation_index: number;
23
+ purpose: 'receive' | 'change' | 'request';
24
+ memo?: string;
25
+ timestamp: string;
26
+ } | {
27
+ type: 'policy_decision';
28
+ decision: 'allow' | 'deny' | 'queue';
29
+ /** Which policy rule decided, e.g. "daily_budget_sats" or "default". */
30
+ rule: string;
31
+ reason: string;
32
+ address: string;
33
+ amount_sats: number;
34
+ memo?: string;
35
+ timestamp: string;
36
+ decision_id: string;
37
+ /** Present when decision === "queue". */
38
+ approval_id?: string;
39
+ confirmed_only?: boolean;
40
+ } | {
41
+ type: 'approval_resolved';
42
+ approval_id: string;
43
+ resolution: 'approved' | 'rejected';
44
+ timestamp: string;
45
+ /** Present when resolution === "approved" and the send broadcast. */
46
+ txid?: string;
47
+ };
48
+ export declare function appendLedger(network: Network, entry: LedgerEntry): void;
49
+ /** Unique addresses this wallet has issued, oldest first (no unlock needed). */
50
+ export declare function trackedAddressesFromLedger(network: Network): string[];
51
+ export declare function readLedger(network: Network): LedgerEntry[];
package/dist/ledger.js ADDED
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ledgerPath } from './paths.js';
4
+ export function appendLedger(network, entry) {
5
+ const file = ledgerPath(network);
6
+ fs.mkdirSync(path.dirname(file), { recursive: true });
7
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n', { mode: 0o600 });
8
+ }
9
+ /** Unique addresses this wallet has issued, oldest first (no unlock needed). */
10
+ export function trackedAddressesFromLedger(network) {
11
+ const seen = new Set();
12
+ for (const entry of readLedger(network)) {
13
+ if (entry.type === 'address_issued')
14
+ seen.add(entry.address);
15
+ }
16
+ return [...seen];
17
+ }
18
+ export function readLedger(network) {
19
+ const file = ledgerPath(network);
20
+ if (!fs.existsSync(file))
21
+ return [];
22
+ return fs
23
+ .readFileSync(file, 'utf8')
24
+ .split('\n')
25
+ .filter((line) => line.trim() !== '')
26
+ .map((line) => JSON.parse(line));
27
+ }
@@ -0,0 +1,32 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ChainProvider } from '../chain/provider.js';
3
+ import type { Config } from '../config.js';
4
+ import type { Network } from '../paths.js';
5
+ import type { CoreWallet } from '../core/wallet.js';
6
+ import type { Brc100Interface } from '../wallet/brc100.js';
7
+ /**
8
+ * The bsv-pay MCP server: tools over the core library, nothing else. The
9
+ * wallet is unlocked BEFORE this server is built (env passphrase or TTY
10
+ * prompt at startup) and no tool can unlock, lock, or export anything —
11
+ * the agent on the other end of the transport never holds a secret. All
12
+ * spending goes through core, where the policy gate and the single-flight
13
+ * spend lock live; this module cannot reach the network or a key directly.
14
+ *
15
+ * Results contract (stable, additive-only once shipped): every tool returns
16
+ * structuredContent with `ok: true | false`. Expected failures — policy
17
+ * denials, queued approvals, insufficient funds — are RESULTS with stable
18
+ * snake_case `error` codes and the engine's data fields (remaining_sats,
19
+ * approval_id, ...), never protocol errors, so an agent can read them and
20
+ * adapt. Only unexpected exceptions surface as isError tool results.
21
+ */
22
+ export interface McpServerOptions {
23
+ network: Network;
24
+ wallet: CoreWallet;
25
+ config?: Partial<Config>;
26
+ /** Tests inject a mock; production uses the default (WhatsOnChain). */
27
+ provider?: ChainProvider;
28
+ /** Tests inject a mock BRC-100 wallet app (used only under brc100 custody). */
29
+ brc100?: Brc100Interface;
30
+ }
31
+ export declare const MCP_SERVER_VERSION = "0.2.0";
32
+ export declare function buildMcpServer(opts: McpServerOptions): McpServer;