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,484 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { CliError, usageError } from '../errors.js';
|
|
4
|
+
import { getBalance } from '../core/balance.js';
|
|
5
|
+
import { getHistory } from '../core/history.js';
|
|
6
|
+
import { getPolicyStatus } from '../core/policy-status.js';
|
|
7
|
+
import { createRequest, awaitPayment } from '../core/request.js';
|
|
8
|
+
import { send } from '../core/send.js';
|
|
9
|
+
import { paidFetch } from '../http402/client.js';
|
|
10
|
+
export const MCP_SERVER_VERSION = '0.2.0';
|
|
11
|
+
/** Fields shared by every tool result. */
|
|
12
|
+
const ENVELOPE = {
|
|
13
|
+
ok: z.boolean().describe('True when the call succeeded; false carries error + message.'),
|
|
14
|
+
code: z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Stable bsv-pay error number (same meanings as the CLI exit codes).'),
|
|
19
|
+
error: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe('Stable snake_case error code, e.g. "daily_budget_exceeded".'),
|
|
23
|
+
message: z.string().optional().describe('Human-readable explanation when ok is false.'),
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Detail fields the policy engine attaches to refusals/queues; flattened
|
|
27
|
+
* into ok:false results so agents can plan (e.g. remaining_sats) without
|
|
28
|
+
* parsing prose. All optional: which appear depends on the deciding rule.
|
|
29
|
+
*/
|
|
30
|
+
const POLICY_DETAIL_FIELDS = {
|
|
31
|
+
rule: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe('Policy rule that decided, e.g. "daily_budget_sats" or "denylist".'),
|
|
35
|
+
address: z.string().optional(),
|
|
36
|
+
amount_sats: z.number().int().optional(),
|
|
37
|
+
limit_sats: z.number().int().optional(),
|
|
38
|
+
budget_sats: z.number().int().optional(),
|
|
39
|
+
spent_sats: z.number().int().optional(),
|
|
40
|
+
remaining_sats: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe('Satoshis still spendable under the rule that refused this payment.'),
|
|
45
|
+
threshold_sats: z.number().int().optional(),
|
|
46
|
+
approval_id: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe('Present when queued: only a human can release it with "bsv-pay approvals approve <id>".'),
|
|
50
|
+
limit: z.number().int().optional().describe('Rate limit ceiling (payments per window).'),
|
|
51
|
+
window: z.string().optional().describe('Rate limit window: "minute" or "hour".'),
|
|
52
|
+
sent: z.number().int().optional().describe('Payments already made in that window.'),
|
|
53
|
+
};
|
|
54
|
+
function asResult(structured) {
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: 'text', text: JSON.stringify(structured) }],
|
|
57
|
+
structuredContent: structured,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run a tool body; map CliError (typed, stable-coded, key-free by
|
|
62
|
+
* invariant 1) to a structured ok:false result. Anything else is a bug —
|
|
63
|
+
* rethrow and let the SDK report it as a generic tool error.
|
|
64
|
+
*/
|
|
65
|
+
async function guard(fn) {
|
|
66
|
+
try {
|
|
67
|
+
return asResult({ ok: true, ...(await fn()) });
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e instanceof CliError) {
|
|
71
|
+
return asResult({
|
|
72
|
+
ok: false,
|
|
73
|
+
code: e.exitCode,
|
|
74
|
+
error: e.errorCode,
|
|
75
|
+
message: e.message,
|
|
76
|
+
...(e.data ?? {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function buildMcpServer(opts) {
|
|
83
|
+
const core = {
|
|
84
|
+
network: opts.network,
|
|
85
|
+
config: opts.config,
|
|
86
|
+
provider: opts.provider,
|
|
87
|
+
brc100: opts.brc100,
|
|
88
|
+
};
|
|
89
|
+
const networkLabel = opts.network === 'test' ? 'BSV testnet' : 'BSV MAINNET (real money)';
|
|
90
|
+
const server = new McpServer({ name: 'bsv-pay', version: MCP_SERVER_VERSION });
|
|
91
|
+
server.registerTool('get_balance', {
|
|
92
|
+
title: 'Get wallet balance',
|
|
93
|
+
description: `Check this wallet's balance on ${networkLabel}. All amounts are satoshis ` +
|
|
94
|
+
'(1 BSV = 100,000,000 satoshis). Returns confirmed and unconfirmed totals ' +
|
|
95
|
+
'across every address the wallet tracks. Note that spending is governed by ' +
|
|
96
|
+
'a human-set policy, so the spendable amount may be lower than the balance — ' +
|
|
97
|
+
'use get_policy_status to see the actual allowance.',
|
|
98
|
+
annotations: { readOnlyHint: true },
|
|
99
|
+
inputSchema: {},
|
|
100
|
+
outputSchema: {
|
|
101
|
+
...ENVELOPE,
|
|
102
|
+
network: z.enum(['main', 'test']).optional(),
|
|
103
|
+
confirmed_sats: z.number().int().optional(),
|
|
104
|
+
unconfirmed_sats: z.number().int().optional(),
|
|
105
|
+
total_sats: z.number().int().optional(),
|
|
106
|
+
addresses_tracked: z.number().int().optional(),
|
|
107
|
+
},
|
|
108
|
+
}, () => guard(async () => {
|
|
109
|
+
const balance = await getBalance(core);
|
|
110
|
+
return {
|
|
111
|
+
network: opts.network,
|
|
112
|
+
confirmed_sats: balance.confirmedSats,
|
|
113
|
+
unconfirmed_sats: balance.unconfirmedSats,
|
|
114
|
+
total_sats: balance.confirmedSats + balance.unconfirmedSats,
|
|
115
|
+
addresses_tracked: balance.addresses.length,
|
|
116
|
+
};
|
|
117
|
+
}));
|
|
118
|
+
server.registerTool('get_history', {
|
|
119
|
+
title: 'Get payment history',
|
|
120
|
+
description: 'List payments this wallet has sent and received, newest first, from the ' +
|
|
121
|
+
'local append-only ledger (fast, offline, includes local-only memos). ' +
|
|
122
|
+
'All amounts are satoshis.',
|
|
123
|
+
annotations: { readOnlyHint: true },
|
|
124
|
+
inputSchema: {
|
|
125
|
+
limit: z
|
|
126
|
+
.number()
|
|
127
|
+
.int()
|
|
128
|
+
.min(1)
|
|
129
|
+
.max(1000)
|
|
130
|
+
.optional()
|
|
131
|
+
.describe('Maximum entries to return (default 20).'),
|
|
132
|
+
type: z
|
|
133
|
+
.enum(['send', 'receive'])
|
|
134
|
+
.optional()
|
|
135
|
+
.describe('Only sends or only receives; default both.'),
|
|
136
|
+
},
|
|
137
|
+
outputSchema: {
|
|
138
|
+
...ENVELOPE,
|
|
139
|
+
network: z.enum(['main', 'test']).optional(),
|
|
140
|
+
count: z.number().int().optional(),
|
|
141
|
+
payments: z
|
|
142
|
+
.array(z.object({
|
|
143
|
+
type: z.enum(['send', 'receive']),
|
|
144
|
+
txid: z.string(),
|
|
145
|
+
amount_sats: z.number().int(),
|
|
146
|
+
address: z.string(),
|
|
147
|
+
memo: z.string().optional(),
|
|
148
|
+
timestamp: z.string(),
|
|
149
|
+
status: z.enum(['pending', 'confirmed', 'unknown']),
|
|
150
|
+
fee_sats: z.number().int().optional(),
|
|
151
|
+
decision_id: z
|
|
152
|
+
.string()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe('Links a send to the policy decision that authorized it.'),
|
|
155
|
+
}))
|
|
156
|
+
.optional(),
|
|
157
|
+
},
|
|
158
|
+
}, (args) => guard(() => {
|
|
159
|
+
const payments = getHistory(core, { limit: args.limit ?? 20, type: args.type });
|
|
160
|
+
return { network: opts.network, count: payments.length, payments };
|
|
161
|
+
}));
|
|
162
|
+
server.registerTool('get_policy_status', {
|
|
163
|
+
title: 'Get spending policy status',
|
|
164
|
+
description: 'Check the human-set spending policy: per-transaction limits, remaining ' +
|
|
165
|
+
'daily/session budgets, rate-limit headroom, allow/denylists, and payments ' +
|
|
166
|
+
'queued for human approval. All amounts are satoshis. Call this BEFORE ' +
|
|
167
|
+
'paying and plan within the remaining allowance — payments outside policy ' +
|
|
168
|
+
'are refused, the policy cannot be changed or bypassed from this server, ' +
|
|
169
|
+
'and BSV payments are irreversible once sent.',
|
|
170
|
+
annotations: { readOnlyHint: true },
|
|
171
|
+
inputSchema: {},
|
|
172
|
+
outputSchema: {
|
|
173
|
+
...ENVELOPE,
|
|
174
|
+
network: z.enum(['main', 'test']).optional(),
|
|
175
|
+
source: z
|
|
176
|
+
.enum(['defaults', 'file'])
|
|
177
|
+
.optional()
|
|
178
|
+
.describe('"file" when ~/.bsv-pay/policy.toml governs; "defaults" otherwise.'),
|
|
179
|
+
per_tx_limit_sats: z
|
|
180
|
+
.number()
|
|
181
|
+
.int()
|
|
182
|
+
.optional()
|
|
183
|
+
.describe('Hard cap per payment. Absent = no hard cap.'),
|
|
184
|
+
soft_spend_limit_sats: z
|
|
185
|
+
.number()
|
|
186
|
+
.int()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe('Legacy per-payment limit; payments at/above it are refused here.'),
|
|
189
|
+
daily_budget_sats: z.number().int().optional(),
|
|
190
|
+
daily_remaining_sats: z
|
|
191
|
+
.number()
|
|
192
|
+
.int()
|
|
193
|
+
.optional()
|
|
194
|
+
.describe('What may still be spent in the rolling 24h window.'),
|
|
195
|
+
session_budget_sats: z.number().int().optional(),
|
|
196
|
+
session_remaining_sats: z
|
|
197
|
+
.number()
|
|
198
|
+
.int()
|
|
199
|
+
.optional()
|
|
200
|
+
.describe('What may still be spent before this server restarts.'),
|
|
201
|
+
rate_limit_per_minute: z.number().int().optional(),
|
|
202
|
+
remaining_this_minute: z.number().int().optional(),
|
|
203
|
+
rate_limit_per_hour: z.number().int().optional(),
|
|
204
|
+
remaining_this_hour: z.number().int().optional(),
|
|
205
|
+
approval_threshold_sats: z
|
|
206
|
+
.number()
|
|
207
|
+
.int()
|
|
208
|
+
.optional()
|
|
209
|
+
.describe('Payments at/above this queue for human approval instead of sending.'),
|
|
210
|
+
approval_secret_configured: z.boolean().optional(),
|
|
211
|
+
allowlist: z
|
|
212
|
+
.array(z.string())
|
|
213
|
+
.optional()
|
|
214
|
+
.describe('When non-empty, ONLY these addresses may be paid.'),
|
|
215
|
+
denylist: z.array(z.string()).optional().describe('These addresses are never paid.'),
|
|
216
|
+
usage: z
|
|
217
|
+
.object({
|
|
218
|
+
daily_spent_sats: z.number().int(),
|
|
219
|
+
session_spent_sats: z.number().int(),
|
|
220
|
+
sends_last_minute: z.number().int(),
|
|
221
|
+
sends_last_hour: z.number().int(),
|
|
222
|
+
})
|
|
223
|
+
.optional(),
|
|
224
|
+
pending_approvals: z
|
|
225
|
+
.array(z.object({
|
|
226
|
+
approval_id: z.string(),
|
|
227
|
+
address: z.string(),
|
|
228
|
+
amount_sats: z.number().int(),
|
|
229
|
+
memo: z.string().optional(),
|
|
230
|
+
confirmed_only: z.boolean().optional(),
|
|
231
|
+
queued_at: z.string(),
|
|
232
|
+
}))
|
|
233
|
+
.optional()
|
|
234
|
+
.describe('Queued payments only a human (with the approval secret) can release.'),
|
|
235
|
+
},
|
|
236
|
+
}, () => guard(() => {
|
|
237
|
+
const status = getPolicyStatus(core);
|
|
238
|
+
return {
|
|
239
|
+
network: status.network,
|
|
240
|
+
source: status.source,
|
|
241
|
+
per_tx_limit_sats: status.perTxLimitSats,
|
|
242
|
+
soft_spend_limit_sats: status.softPerTxLimitSats,
|
|
243
|
+
daily_budget_sats: status.dailyBudgetSats,
|
|
244
|
+
daily_remaining_sats: status.dailyRemainingSats,
|
|
245
|
+
session_budget_sats: status.sessionBudgetSats,
|
|
246
|
+
session_remaining_sats: status.sessionRemainingSats,
|
|
247
|
+
rate_limit_per_minute: status.rateLimitPerMinute,
|
|
248
|
+
remaining_this_minute: status.remainingThisMinute,
|
|
249
|
+
rate_limit_per_hour: status.rateLimitPerHour,
|
|
250
|
+
remaining_this_hour: status.remainingThisHour,
|
|
251
|
+
approval_threshold_sats: status.approvalThresholdSats,
|
|
252
|
+
approval_secret_configured: status.approvalSecretConfigured,
|
|
253
|
+
allowlist: status.allowlist,
|
|
254
|
+
denylist: status.denylist,
|
|
255
|
+
usage: {
|
|
256
|
+
daily_spent_sats: status.usage.dailySpentSats,
|
|
257
|
+
session_spent_sats: status.usage.sessionSpentSats,
|
|
258
|
+
sends_last_minute: status.usage.sendsLastMinute,
|
|
259
|
+
sends_last_hour: status.usage.sendsLastHour,
|
|
260
|
+
},
|
|
261
|
+
pending_approvals: status.pendingApprovals.map((p) => ({
|
|
262
|
+
approval_id: p.approvalId,
|
|
263
|
+
address: p.address,
|
|
264
|
+
amount_sats: p.amountSats,
|
|
265
|
+
memo: p.memo,
|
|
266
|
+
confirmed_only: p.confirmedOnly,
|
|
267
|
+
queued_at: p.queuedAt,
|
|
268
|
+
})),
|
|
269
|
+
};
|
|
270
|
+
}));
|
|
271
|
+
server.registerTool('pay', {
|
|
272
|
+
title: 'Send a payment',
|
|
273
|
+
description: `Send satoshis to an address on ${networkLabel}. IRREVERSIBLE: a broadcast ` +
|
|
274
|
+
'payment cannot be cancelled or refunded. Amounts are satoshis (1 BSV = ' +
|
|
275
|
+
'100,000,000 satoshis). Every payment is checked against the human-set ' +
|
|
276
|
+
'spending policy (per-payment limits, daily/session budgets, rate limits, ' +
|
|
277
|
+
'allow/denylists). A refused payment returns ok:false with a stable error ' +
|
|
278
|
+
'code and details such as remaining_sats — adapt to the policy (it cannot ' +
|
|
279
|
+
'be changed or bypassed from this server) instead of retrying the same ' +
|
|
280
|
+
'payment. A payment at/above the approval threshold is NOT sent: it returns ' +
|
|
281
|
+
'ok:false with error "pending_approval" and an approval_id that only a ' +
|
|
282
|
+
'human can release with "bsv-pay approvals approve <id>" — do not retry it, ' +
|
|
283
|
+
'that would queue a duplicate. Use get_policy_status first to plan within ' +
|
|
284
|
+
'the allowance.',
|
|
285
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
286
|
+
inputSchema: {
|
|
287
|
+
address: z.string().describe('Recipient BSV address (must match the active network).'),
|
|
288
|
+
amount_sats: z
|
|
289
|
+
.number()
|
|
290
|
+
.int()
|
|
291
|
+
.positive()
|
|
292
|
+
.describe('Amount in satoshis. Bare integer; no BSV decimals.'),
|
|
293
|
+
memo: z
|
|
294
|
+
.string()
|
|
295
|
+
.optional()
|
|
296
|
+
.describe('Local-only note for the wallet ledger; never written on-chain.'),
|
|
297
|
+
},
|
|
298
|
+
outputSchema: {
|
|
299
|
+
...ENVELOPE,
|
|
300
|
+
...POLICY_DETAIL_FIELDS,
|
|
301
|
+
network: z.enum(['main', 'test']).optional(),
|
|
302
|
+
txid: z.string().optional().describe('Transaction id of the broadcast payment.'),
|
|
303
|
+
fee_sats: z.number().int().optional(),
|
|
304
|
+
change_sats: z.number().int().optional(),
|
|
305
|
+
balance_after_sats: z.number().int().optional(),
|
|
306
|
+
explorer_url: z.string().optional(),
|
|
307
|
+
},
|
|
308
|
+
}, (args) => guard(async () => {
|
|
309
|
+
const result = await send(opts.wallet, core, {
|
|
310
|
+
to: args.address,
|
|
311
|
+
amountSats: args.amount_sats,
|
|
312
|
+
memo: args.memo,
|
|
313
|
+
});
|
|
314
|
+
return {
|
|
315
|
+
network: opts.network,
|
|
316
|
+
txid: result.txid,
|
|
317
|
+
address: result.to,
|
|
318
|
+
amount_sats: result.amountSats,
|
|
319
|
+
fee_sats: result.feeSats,
|
|
320
|
+
change_sats: result.changeSats,
|
|
321
|
+
balance_after_sats: result.balanceAfterSats,
|
|
322
|
+
explorer_url: result.explorerUrl,
|
|
323
|
+
};
|
|
324
|
+
}));
|
|
325
|
+
server.registerTool('paid_fetch', {
|
|
326
|
+
title: 'Fetch a URL, paying if required',
|
|
327
|
+
description: 'Fetch an http(s) URL. Free resources cost nothing and return paid:false. ' +
|
|
328
|
+
'If the server responds 402 Payment Required (BRC-105), this SPENDS ' +
|
|
329
|
+
'satoshis from the wallet: the payment goes through the same human-set ' +
|
|
330
|
+
'policy as pay (budgets, limits, lists — refusals are ok:false results ' +
|
|
331
|
+
'with stable codes) and is IRREVERSIBLE once made. Set max_price_sats ' +
|
|
332
|
+
'whenever you do not already know the price — it hard-caps this one ' +
|
|
333
|
+
'fetch regardless of remaining budget. A payment the server then ' +
|
|
334
|
+
'refuses to honor returns ok:false error "payment_not_redeemed" with ' +
|
|
335
|
+
'the txid: the money moved; do not blindly retry, that would pay again.',
|
|
336
|
+
annotations: {
|
|
337
|
+
readOnlyHint: false,
|
|
338
|
+
destructiveHint: true,
|
|
339
|
+
idempotentHint: false,
|
|
340
|
+
openWorldHint: true,
|
|
341
|
+
},
|
|
342
|
+
inputSchema: {
|
|
343
|
+
url: z.string().describe('http(s) URL to fetch.'),
|
|
344
|
+
max_price_sats: z
|
|
345
|
+
.number()
|
|
346
|
+
.int()
|
|
347
|
+
.positive()
|
|
348
|
+
.optional()
|
|
349
|
+
.describe('Refuse to pay more than this for the resource (satoshis).'),
|
|
350
|
+
max_body_chars: z
|
|
351
|
+
.number()
|
|
352
|
+
.int()
|
|
353
|
+
.min(1)
|
|
354
|
+
.max(500_000)
|
|
355
|
+
.optional()
|
|
356
|
+
.describe('Truncate the returned body to this many characters (default 50,000).'),
|
|
357
|
+
},
|
|
358
|
+
outputSchema: {
|
|
359
|
+
...ENVELOPE,
|
|
360
|
+
...POLICY_DETAIL_FIELDS,
|
|
361
|
+
network: z.enum(['main', 'test']).optional(),
|
|
362
|
+
url: z.string().optional(),
|
|
363
|
+
status: z.number().int().optional().describe('HTTP status of the final response.'),
|
|
364
|
+
paid: z.boolean().optional(),
|
|
365
|
+
content_type: z.string().optional(),
|
|
366
|
+
body: z.string().optional(),
|
|
367
|
+
body_truncated: z.boolean().optional(),
|
|
368
|
+
txid: z.string().optional(),
|
|
369
|
+
fee_sats: z.number().int().optional(),
|
|
370
|
+
price_sats: z.number().int().optional(),
|
|
371
|
+
max_price_sats: z.number().int().optional(),
|
|
372
|
+
},
|
|
373
|
+
}, (args) => guard(async () => {
|
|
374
|
+
const result = await paidFetch(opts.wallet, core, {
|
|
375
|
+
url: args.url,
|
|
376
|
+
maxPriceSats: args.max_price_sats,
|
|
377
|
+
});
|
|
378
|
+
const cap = args.max_body_chars ?? 50_000;
|
|
379
|
+
const truncated = result.body.length > cap;
|
|
380
|
+
return {
|
|
381
|
+
network: opts.network,
|
|
382
|
+
url: args.url,
|
|
383
|
+
status: result.status,
|
|
384
|
+
paid: result.paid,
|
|
385
|
+
content_type: result.contentType,
|
|
386
|
+
body: truncated ? result.body.slice(0, cap) : result.body,
|
|
387
|
+
body_truncated: truncated,
|
|
388
|
+
...(result.payment && {
|
|
389
|
+
txid: result.payment.txid,
|
|
390
|
+
amount_sats: result.payment.amountSats,
|
|
391
|
+
fee_sats: result.payment.feeSats,
|
|
392
|
+
address: result.payment.address,
|
|
393
|
+
}),
|
|
394
|
+
};
|
|
395
|
+
}));
|
|
396
|
+
server.registerTool('create_payment_request', {
|
|
397
|
+
title: 'Create a payment request',
|
|
398
|
+
description: 'Request a payment INTO this wallet: issues a fresh receiving address and a ' +
|
|
399
|
+
'BIP-21 payment URI to hand to the payer. Amounts are satoshis (1 BSV = ' +
|
|
400
|
+
'100,000,000 satoshis). Costs nothing and is governed by no budget — only ' +
|
|
401
|
+
'pay (outgoing) is policy-limited. Follow up with await_payment on the ' +
|
|
402
|
+
'returned address to detect when it is paid.',
|
|
403
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
404
|
+
inputSchema: {
|
|
405
|
+
amount_sats: z
|
|
406
|
+
.number()
|
|
407
|
+
.int()
|
|
408
|
+
.positive()
|
|
409
|
+
.describe('Amount to request, in satoshis. Encoded in the returned URI.'),
|
|
410
|
+
memo: z
|
|
411
|
+
.string()
|
|
412
|
+
.optional()
|
|
413
|
+
.describe('Local-only request label; also set as the URI label for the payer.'),
|
|
414
|
+
},
|
|
415
|
+
outputSchema: {
|
|
416
|
+
...ENVELOPE,
|
|
417
|
+
network: z.enum(['main', 'test']).optional(),
|
|
418
|
+
address: z.string().optional().describe('Fresh address issued for exactly this request.'),
|
|
419
|
+
amount_sats: z.number().int().optional(),
|
|
420
|
+
memo: z.string().optional(),
|
|
421
|
+
uri: z.string().optional().describe('BIP-21 payment URI (bitcoin:<address>?sv&amount=…).'),
|
|
422
|
+
},
|
|
423
|
+
}, (args) => guard(() => {
|
|
424
|
+
const request = createRequest(opts.wallet, {
|
|
425
|
+
amountSats: args.amount_sats,
|
|
426
|
+
memo: args.memo,
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
network: request.network,
|
|
430
|
+
address: request.address,
|
|
431
|
+
amount_sats: request.amountSats,
|
|
432
|
+
memo: request.memo,
|
|
433
|
+
uri: request.uri,
|
|
434
|
+
};
|
|
435
|
+
}));
|
|
436
|
+
server.registerTool('await_payment', {
|
|
437
|
+
title: 'Wait for an incoming payment',
|
|
438
|
+
description: 'Wait for the first incoming payment on an address THIS wallet issued ' +
|
|
439
|
+
'(create_payment_request first). Polls the chain until a payment is seen at ' +
|
|
440
|
+
'0-conf, then records it in the ledger and returns it; amounts are ' +
|
|
441
|
+
'satoshis. On timeout it returns ok:false with error "request_timeout" — ' +
|
|
442
|
+
'the request stays valid and calling await_payment again is safe.',
|
|
443
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
444
|
+
inputSchema: {
|
|
445
|
+
address: z.string().describe('Address returned by create_payment_request.'),
|
|
446
|
+
timeout_s: z
|
|
447
|
+
.number()
|
|
448
|
+
.int()
|
|
449
|
+
.min(1)
|
|
450
|
+
.max(600)
|
|
451
|
+
.optional()
|
|
452
|
+
.describe('Seconds to wait before giving up (default 120, max 600).'),
|
|
453
|
+
},
|
|
454
|
+
outputSchema: {
|
|
455
|
+
...ENVELOPE,
|
|
456
|
+
network: z.enum(['main', 'test']).optional(),
|
|
457
|
+
address: z.string().optional(),
|
|
458
|
+
txid: z.string().optional(),
|
|
459
|
+
amount_sats: z.number().int().optional().describe('Satoshis received.'),
|
|
460
|
+
confirmed: z
|
|
461
|
+
.boolean()
|
|
462
|
+
.optional()
|
|
463
|
+
.describe('False = seen at 0-conf (normal for fresh payments).'),
|
|
464
|
+
timeout_ms: z.number().int().optional(),
|
|
465
|
+
},
|
|
466
|
+
}, (args) => guard(async () => {
|
|
467
|
+
if (!opts.wallet.addresses().includes(args.address)) {
|
|
468
|
+
throw usageError('unknown_address', `Address ${args.address} was not issued by this wallet. ` +
|
|
469
|
+
'Use create_payment_request and await the address it returns.');
|
|
470
|
+
}
|
|
471
|
+
const payment = await awaitPayment(core, {
|
|
472
|
+
address: args.address,
|
|
473
|
+
timeoutMs: (args.timeout_s ?? 120) * 1000,
|
|
474
|
+
});
|
|
475
|
+
return {
|
|
476
|
+
network: opts.network,
|
|
477
|
+
address: payment.address,
|
|
478
|
+
txid: payment.txid,
|
|
479
|
+
amount_sats: payment.receivedSats,
|
|
480
|
+
confirmed: payment.confirmed,
|
|
481
|
+
};
|
|
482
|
+
}));
|
|
483
|
+
return server;
|
|
484
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output discipline (invariant 2): in --json mode, stdout carries exactly one
|
|
3
|
+
* JSON object (or NDJSON lines for watch) and nothing else; all human text
|
|
4
|
+
* goes to stderr. Chalk auto-disables color when the stream is not a TTY.
|
|
5
|
+
*/
|
|
6
|
+
export declare class Output {
|
|
7
|
+
readonly json: boolean;
|
|
8
|
+
constructor(json: boolean);
|
|
9
|
+
/** Human-facing informational text. stdout normally, suppressed in --json mode. */
|
|
10
|
+
info(text: string): void;
|
|
11
|
+
/** Human-facing notice that must survive --json mode (warnings, prompts context) — goes to stderr. */
|
|
12
|
+
warn(text: string): void;
|
|
13
|
+
/** The single JSON result object (or one NDJSON event line). */
|
|
14
|
+
result(obj: Record<string, unknown>): void;
|
|
15
|
+
/** Render an error: human message to stderr; JSON error object to stdout in --json mode. */
|
|
16
|
+
error(err: unknown): number;
|
|
17
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { CliError, EXIT } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Output discipline (invariant 2): in --json mode, stdout carries exactly one
|
|
5
|
+
* JSON object (or NDJSON lines for watch) and nothing else; all human text
|
|
6
|
+
* goes to stderr. Chalk auto-disables color when the stream is not a TTY.
|
|
7
|
+
*/
|
|
8
|
+
export class Output {
|
|
9
|
+
json;
|
|
10
|
+
constructor(json) {
|
|
11
|
+
this.json = json;
|
|
12
|
+
}
|
|
13
|
+
/** Human-facing informational text. stdout normally, suppressed in --json mode. */
|
|
14
|
+
info(text) {
|
|
15
|
+
if (!this.json)
|
|
16
|
+
process.stdout.write(text + '\n');
|
|
17
|
+
}
|
|
18
|
+
/** Human-facing notice that must survive --json mode (warnings, prompts context) — goes to stderr. */
|
|
19
|
+
warn(text) {
|
|
20
|
+
process.stderr.write(chalk.yellow(text) + '\n');
|
|
21
|
+
}
|
|
22
|
+
/** The single JSON result object (or one NDJSON event line). */
|
|
23
|
+
result(obj) {
|
|
24
|
+
if (this.json)
|
|
25
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
26
|
+
}
|
|
27
|
+
/** Render an error: human message to stderr; JSON error object to stdout in --json mode. */
|
|
28
|
+
error(err) {
|
|
29
|
+
const cliErr = err instanceof CliError
|
|
30
|
+
? err
|
|
31
|
+
: new CliError(EXIT.UNEXPECTED, 'unexpected_error', err instanceof Error ? err.message : String(err));
|
|
32
|
+
process.stderr.write(chalk.red(`Error: ${cliErr.message}`) + '\n');
|
|
33
|
+
if (this.json) {
|
|
34
|
+
process.stdout.write(JSON.stringify({
|
|
35
|
+
ok: false,
|
|
36
|
+
code: cliErr.exitCode,
|
|
37
|
+
error: cliErr.errorCode,
|
|
38
|
+
message: cliErr.message,
|
|
39
|
+
...cliErr.data,
|
|
40
|
+
}) + '\n');
|
|
41
|
+
}
|
|
42
|
+
return cliErr.exitCode;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Network = 'main' | 'test';
|
|
2
|
+
/** Base state directory; BSV_PAY_HOME overrides for tests. */
|
|
3
|
+
export declare function baseDir(): string;
|
|
4
|
+
export declare function configPath(): string;
|
|
5
|
+
/** Testnet state lives separately from mainnet state (invariant 7). */
|
|
6
|
+
export declare function walletPath(network: Network): string;
|
|
7
|
+
export declare function ledgerPath(network: Network): string;
|
|
8
|
+
/** One policy file for both networks (per-network overrides live inside it). */
|
|
9
|
+
export declare function policyPath(): string;
|
|
10
|
+
/** Argon2id hash of the human approval secret — never the secret itself. */
|
|
11
|
+
export declare function approvalSecretPath(): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Base state directory; BSV_PAY_HOME overrides for tests. */
|
|
4
|
+
export function baseDir() {
|
|
5
|
+
return process.env.BSV_PAY_HOME ?? path.join(os.homedir(), '.bsv-pay');
|
|
6
|
+
}
|
|
7
|
+
export function configPath() {
|
|
8
|
+
return path.join(baseDir(), 'config.toml');
|
|
9
|
+
}
|
|
10
|
+
/** Testnet state lives separately from mainnet state (invariant 7). */
|
|
11
|
+
export function walletPath(network) {
|
|
12
|
+
return path.join(baseDir(), network === 'test' ? 'wallet-testnet.json' : 'wallet.json');
|
|
13
|
+
}
|
|
14
|
+
export function ledgerPath(network) {
|
|
15
|
+
return path.join(baseDir(), network === 'test' ? 'ledger-testnet.jsonl' : 'ledger.jsonl');
|
|
16
|
+
}
|
|
17
|
+
/** One policy file for both networks (per-network overrides live inside it). */
|
|
18
|
+
export function policyPath() {
|
|
19
|
+
return path.join(baseDir(), 'policy.toml');
|
|
20
|
+
}
|
|
21
|
+
/** Argon2id hash of the human approval secret — never the secret itself. */
|
|
22
|
+
export function approvalSecretPath() {
|
|
23
|
+
return path.join(baseDir(), 'approval-secret.json');
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CliError } from '../errors.js';
|
|
2
|
+
import { type Network } from '../paths.js';
|
|
3
|
+
export declare function approvalSecretConfigured(): boolean;
|
|
4
|
+
/** Store the argon2id hash of a new approval secret (never the secret). */
|
|
5
|
+
export declare function storeApprovalSecret(secret: string): void;
|
|
6
|
+
export declare function verifyApprovalSecret(secret: string): boolean;
|
|
7
|
+
export interface PendingApproval {
|
|
8
|
+
approvalId: string;
|
|
9
|
+
address: string;
|
|
10
|
+
amountSats: number;
|
|
11
|
+
memo?: string;
|
|
12
|
+
confirmedOnly?: boolean;
|
|
13
|
+
queuedAt: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Pending approvals are a fold over the append-only ledger: queue decisions
|
|
17
|
+
* minus resolutions. No second mutable state file to tamper with.
|
|
18
|
+
*/
|
|
19
|
+
export declare function listPendingApprovals(network: Network): PendingApproval[];
|
|
20
|
+
/** Find a pending approval by full id or unambiguous prefix. */
|
|
21
|
+
export declare function findPendingApproval(network: Network, id: string): PendingApproval;
|
|
22
|
+
export declare function resolveApproval(network: Network, approvalId: string, resolution: 'approved' | 'rejected', txid?: string): void;
|
|
23
|
+
/** Thrown when the typed approval secret is wrong. Exit 7 (auth failure). */
|
|
24
|
+
export declare function badApprovalSecret(): CliError;
|