nostr-bray 3.4.2 → 3.5.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 (48) hide show
  1. package/README.md +77 -10
  2. package/dist/bunker-context.d.ts +18 -0
  3. package/dist/bunker-context.js +20 -5
  4. package/dist/catalog.d.ts +11 -1
  5. package/dist/catalog.js +28 -1
  6. package/dist/cli/commands/wallet.js +2 -1
  7. package/dist/cli/commands/zap.js +2 -2
  8. package/dist/cli/index.js +2 -0
  9. package/dist/config.d.ts +2 -0
  10. package/dist/config.js +9 -0
  11. package/dist/dispatch/tools.js +1 -1
  12. package/dist/file-lock.d.ts +2 -0
  13. package/dist/file-lock.js +71 -0
  14. package/dist/heartwood-context.d.ts +53 -7
  15. package/dist/heartwood-context.js +120 -22
  16. package/dist/http-token.d.ts +8 -0
  17. package/dist/http-token.js +18 -0
  18. package/dist/identity/tools.d.ts +3 -0
  19. package/dist/index.js +17 -64
  20. package/dist/marketplace/handlers.d.ts +8 -3
  21. package/dist/marketplace/handlers.js +21 -4
  22. package/dist/marketplace/tools.js +38 -6
  23. package/dist/sdk.js +4 -1
  24. package/dist/tool-groups.d.ts +16 -0
  25. package/dist/tool-groups.js +52 -0
  26. package/dist/types.d.ts +2 -0
  27. package/dist/version.d.ts +1 -0
  28. package/dist/version.js +6 -0
  29. package/dist/wallet-service/grants.d.ts +24 -1
  30. package/dist/wallet-service/grants.js +172 -20
  31. package/dist/wallet-service/service.d.ts +9 -3
  32. package/dist/wallet-service/service.js +136 -36
  33. package/dist/wallet-service/tools.d.ts +10 -1
  34. package/dist/wallet-service/tools.js +101 -111
  35. package/dist/wallet-service/upstream.d.ts +2 -0
  36. package/dist/wallet-service/upstream.js +41 -5
  37. package/dist/zap/confirm.d.ts +16 -0
  38. package/dist/zap/confirm.js +58 -0
  39. package/dist/zap/handlers.d.ts +10 -1
  40. package/dist/zap/handlers.js +56 -6
  41. package/dist/zap/index.d.ts +1 -0
  42. package/dist/zap/index.js +1 -0
  43. package/dist/zap/payment-guard.d.ts +59 -0
  44. package/dist/zap/payment-guard.js +213 -0
  45. package/dist/zap/tools.js +29 -2
  46. package/llms-full.txt +13 -9
  47. package/llms.txt +4 -4
  48. package/package.json +18 -4
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { resolveNwcUri } from '../zap/handlers.js';
3
- import { defaultGrantsFile, loadGrants, saveGrants } from './grants.js';
3
+ import { acquireServeLock, defaultGrantsFile, fileGrantStore, loadGrants, refillGrant, releaseServeLock, revokeGrant, updateGrants, writeGrantUriFile, } from './grants.js';
4
4
  import { upstreamWallet } from './upstream.js';
5
- import { DEFAULT_METHODS, SUPPORTED_METHODS, WalletService, grantUri, newGrant, remainingBudgetMsat, } from './service.js';
5
+ import { defaultPaymentGuard } from '../zap/payment-guard.js';
6
+ import { DEFAULT_METHODS, SUPPORTED_METHODS, WalletService, newGrant, remainingBudgetMsat, } from './service.js';
6
7
  // Handing out a wallet connection that is narrower than the one you hold.
7
8
  //
8
9
  // `zap-send` spends through the operator's own NWC URI, which is an
@@ -17,13 +18,13 @@ import { DEFAULT_METHODS, SUPPORTED_METHODS, WalletService, grantUri, newGrant,
17
18
  // leaving someone to discover it: a grant handed to an agent that only
18
19
  // works while an MCP session is open is a different promise from one that
19
20
  // works overnight.
20
- // The running service, and the grant objects it was handed. The service
21
- // mutates those in place - a budget it has spent, a request id it has
22
- // answered - so persisting means writing THOSE back, not re-writing
23
- // whatever the file already said.
21
+ // The running service. It keeps no authority of its own: every grant it
22
+ // answers for is re-read from the grants file before each request and
23
+ // changed there under a lock, and only one process may serve at a time.
24
24
  let service = null;
25
25
  let servingFor = null;
26
- const served = new Map();
26
+ let lockedFile = null;
27
+ let releaseOnExit = false;
27
28
  const text = (value) => ({
28
29
  content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
29
30
  });
@@ -38,87 +39,93 @@ const shown = (grant) => ({
38
39
  ...(grant.maxPaymentMsat === undefined ? {} : { maxPaymentMsat: grant.maxPaymentMsat }),
39
40
  ...(grant.lastUsedAt === undefined ? {} : { lastUsedAt: new Date(grant.lastUsedAt).toISOString() }),
40
41
  });
41
- export function registerWalletServiceTools(server, deps) {
42
+ export function registerWalletServiceTools(server, deps, options = {}) {
43
+ const issuing = options.issuing === true;
42
44
  const grantsFile = defaultGrantsFile();
43
45
  const identity = () => deps.ctx.activePublicKeyHex;
44
46
  const read = () => loadGrants(grantsFile, identity());
45
- const write = (grants) => {
46
- saveGrants(grantsFile, identity(), grants);
47
+ const stopService = () => {
48
+ service?.close();
49
+ service = null;
50
+ servingFor = null;
51
+ if (lockedFile)
52
+ releaseServeLock(lockedFile);
53
+ lockedFile = null;
47
54
  };
48
55
  const running = async () => {
49
56
  const pubkey = identity();
50
57
  // An identity switch is a different wallet and different grants, so the
51
58
  // old service is stopped rather than left answering under a name this
52
59
  // session no longer means.
53
- if (service && servingFor !== pubkey) {
54
- service.close();
55
- service = null;
56
- served.clear();
57
- }
60
+ if (service && servingFor !== pubkey)
61
+ stopService();
58
62
  if (service)
59
63
  return service;
60
64
  const uri = resolveNwcUri(deps.ctx, deps.walletsFile, deps.nwcUri);
61
65
  if (!uri)
62
66
  throw new Error('No wallet is configured - `zap-wallet-set` points this identity at one first.');
67
+ acquireServeLock(grantsFile);
68
+ lockedFile = grantsFile;
69
+ if (!releaseOnExit) {
70
+ releaseOnExit = true;
71
+ process.once('exit', () => { if (lockedFile)
72
+ releaseServeLock(lockedFile); });
73
+ }
63
74
  servingFor = pubkey;
64
75
  service = new WalletService({
65
- wallet: upstreamWallet({ uri, alias: deps.ctx.activeNpub }),
76
+ wallet: upstreamWallet({ uri, alias: deps.ctx.activeNpub, guard: defaultPaymentGuard() }),
66
77
  transport: {
67
78
  subscribe: (relays, filter, onEvent) => deps.pool.subscribe(relays, filter, onEvent),
68
79
  publish: async (relays, event) => {
69
80
  await deps.pool.publishDirect(relays, event);
70
81
  },
71
82
  },
72
- persist: async () => {
73
- // The in-memory grants win: they are the ones the service has been
74
- // charging. Anything on disk it is not serving is carried through
75
- // untouched.
76
- const stored = loadGrants(grantsFile, pubkey);
77
- saveGrants(grantsFile, pubkey, stored.map((held) => served.get(held.id) ?? held));
78
- },
83
+ store: fileGrantStore(grantsFile, pubkey),
79
84
  });
80
85
  return service;
81
86
  };
82
87
  const startServing = async (grant) => {
83
- served.set(grant.id, grant);
84
88
  await (await running()).serve(grant);
85
89
  };
86
- server.registerTool('wallet-grant', {
87
- description: 'Issue a NIP-47 connection over this identity\'s wallet, narrower than the URI you hold. Defaults to invoice-only: no spending and no balance disclosure. A connection that can spend must carry a budget. Returns the nostr+walletconnect:// URI to hand over.',
88
- inputSchema: {
89
- name: z.string().describe('What this connection is for - it is how you revoke the right one later'),
90
- methods: z
91
- .array(z.enum(SUPPORTED_METHODS))
92
- .optional()
93
- .describe(`NIP-47 methods this connection may call. Default: ${DEFAULT_METHODS.join(', ')}`),
94
- budgetMsat: z.number().int().positive().optional().describe('Total it may ever spend, in msat. Required with pay_invoice'),
95
- maxPaymentMsat: z.number().int().positive().optional().describe('Ceiling on any single payment, in msat'),
96
- relays: z.array(z.string()).optional().describe('Relays to answer on. Defaults to this identity\'s write relays'),
97
- },
98
- annotations: { readOnlyHint: false, destructiveHint: true },
99
- }, async ({ name, methods, budgetMsat, maxPaymentMsat, relays }) => {
100
- const grants = read();
101
- if (grants.some((held) => !held.revokedAt && held.name.toLowerCase() === name.trim().toLowerCase())) {
102
- throw new Error(`There is already a live connection called ${name}.`);
103
- }
104
- const on = relays?.length ? relays : deps.pool.getRelays(deps.ctx.activeNpub).write;
105
- const grant = newGrant({
106
- name: name.trim(),
107
- relays: on,
108
- ...(methods ? { methods: [...methods] } : {}),
109
- ...(budgetMsat === undefined ? {} : { budgetMsat }),
110
- ...(maxPaymentMsat === undefined ? {} : { maxPaymentMsat }),
111
- });
112
- write([...grants, grant]);
113
- if (service)
114
- await startServing(grant);
115
- return text({
116
- ok: true,
117
- ...shown(grant),
118
- uri: grantUri(grant),
119
- note: 'Whoever holds this URI can do exactly the above and nothing else. It is answered only while `wallet-serve` is running.',
90
+ if (issuing)
91
+ server.registerTool('wallet-grant', {
92
+ description: 'Issue a scoped NIP-47 connection over this identity\'s one wallet, narrower than the URI you hold. Defaults to invoice-only: no spending and no balance disclosure. A connection that can spend must carry a budget. Writes the nostr+walletconnect:// URI to a private 0600 file and returns its path; the URI itself is never returned.',
93
+ inputSchema: {
94
+ name: z.string().describe('What this connection is for - it is how you revoke the right one later'),
95
+ methods: z
96
+ .array(z.enum(SUPPORTED_METHODS))
97
+ .optional()
98
+ .describe(`NIP-47 methods this connection may call. Default: ${DEFAULT_METHODS.join(', ')}`),
99
+ budgetMsat: z.number().int().positive().optional().describe('Total it may ever spend, in msat. Required with pay_invoice'),
100
+ maxPaymentMsat: z.number().int().positive().optional().describe('Ceiling on any single payment, in msat'),
101
+ relays: z.array(z.string()).optional().describe('Relays to answer on. Defaults to this identity\'s write relays'),
102
+ },
103
+ annotations: { readOnlyHint: false, destructiveHint: true },
104
+ }, async ({ name, methods, budgetMsat, maxPaymentMsat, relays }) => {
105
+ const on = relays?.length ? relays : deps.pool.getRelays(deps.ctx.activeNpub).write;
106
+ const grant = newGrant({
107
+ name: name.trim(),
108
+ relays: on,
109
+ ...(methods ? { methods: [...methods] } : {}),
110
+ ...(budgetMsat === undefined ? {} : { budgetMsat }),
111
+ ...(maxPaymentMsat === undefined ? {} : { maxPaymentMsat }),
112
+ });
113
+ updateGrants(grantsFile, identity(), (grants) => {
114
+ if (grants.some((held) => !held.revokedAt && held.name.toLowerCase() === grant.name.toLowerCase())) {
115
+ throw new Error(`There is already a live connection called ${name}.`);
116
+ }
117
+ grants.push(grant);
118
+ });
119
+ const uriFile = writeGrantUriFile(grantsFile, grant);
120
+ if (service)
121
+ await startServing(grant);
122
+ return text({
123
+ ok: true,
124
+ ...shown(grant),
125
+ uriFile,
126
+ note: 'The connection URI is in uriFile (mode 0600) and is deliberately not shown here: it is a bearer secret. Hand that file to whoever should hold the connection. Whoever holds the URI can do exactly the above and nothing else, and it is answered only while `wallet-serve` is running.',
127
+ });
120
128
  });
121
- });
122
129
  server.registerTool('wallet-grants', {
123
130
  description: 'List the NIP-47 connections issued over this identity\'s wallet, what each may do, and what it has spent. Secrets are never returned.',
124
131
  annotations: { readOnlyHint: true },
@@ -131,59 +138,42 @@ export function registerWalletServiceTools(server, deps) {
131
138
  inputSchema: { nameOrId: z.string() },
132
139
  annotations: { readOnlyHint: false, destructiveHint: true },
133
140
  }, async ({ nameOrId }) => {
134
- const grants = read();
135
- const wanted = nameOrId.trim().toLowerCase();
136
- const grant = grants.find((held) => held.id === wanted) ??
137
- grants.find((held) => held.name.toLowerCase() === wanted) ??
138
- grants.find((held) => held.id.startsWith(wanted));
139
- if (!grant)
140
- throw new Error(`No connection here called ${nameOrId}.`);
141
- grant.revokedAt ??= Date.now();
142
- write(grants);
141
+ // Written to the grants file, which a serving process re-reads before
142
+ // every request: this takes effect there too, even when that process
143
+ // is another one.
144
+ const grant = revokeGrant(grantsFile, identity(), nameOrId);
143
145
  service?.stop(grant.id);
144
- served.delete(grant.id);
145
146
  return text({ ok: true, ...shown(grant) });
146
147
  });
147
- server.registerTool('wallet-refill', {
148
- description: 'Put a spending connection\'s budget back where it started, optionally at a new figure. Deliberately separate from granting: topping up is a decision, not a side effect of use.',
149
- inputSchema: { nameOrId: z.string(), budgetMsat: z.number().int().positive().optional() },
150
- annotations: { readOnlyHint: false, destructiveHint: true },
151
- }, async ({ nameOrId, budgetMsat }) => {
152
- const grants = read();
153
- const wanted = nameOrId.trim().toLowerCase();
154
- const grant = grants.find((held) => held.id === wanted) ?? grants.find((held) => held.name.toLowerCase() === wanted);
155
- if (!grant)
156
- throw new Error(`No connection here called ${nameOrId}.`);
157
- if (grant.revokedAt)
158
- throw new Error('That connection is revoked - issue a new one.');
159
- if (grant.budgetMsat === undefined)
160
- throw new Error('That connection cannot spend, so it has no budget.');
161
- if (budgetMsat !== undefined)
162
- grant.budgetMsat = budgetMsat;
163
- grant.spentMsat = 0;
164
- write(grants);
165
- return text({ ok: true, ...shown(grant) });
166
- });
167
- server.registerTool('wallet-serve', {
168
- description: 'Start or stop answering NIP-47 for the connections issued above. While it runs, holders of those URIs can call the methods their grant allows, against this identity\'s wallet. It answers only while this process is alive.',
169
- inputSchema: { action: z.enum(['start', 'stop', 'status']).default('status') },
170
- annotations: { readOnlyHint: false },
171
- }, async ({ action }) => {
172
- if (action === 'stop') {
173
- service?.close();
174
- service = null;
175
- servingFor = null;
176
- served.clear();
177
- return text({ ok: true, serving: [], message: 'Stopped. Nothing is answered until it runs again.' });
178
- }
179
- if (action === 'start') {
180
- const live = read().filter((grant) => !grant.revokedAt);
181
- if (!live.length)
182
- throw new Error('Nothing to serve - `wallet-grant` issues a connection first.');
183
- for (const grant of live)
184
- await startServing(grant);
185
- return text({ ok: true, serving: service?.serving() ?? [], grants: live.map(shown) });
186
- }
187
- return text({ ok: true, running: service !== null, serving: service?.serving() ?? [] });
188
- });
148
+ if (issuing)
149
+ server.registerTool('wallet-refill', {
150
+ description: 'Put a spending connection\'s budget back where it started, optionally at a new figure. Deliberately separate from granting: topping up is a decision, not a side effect of use.',
151
+ inputSchema: { nameOrId: z.string(), budgetMsat: z.number().int().positive().optional() },
152
+ annotations: { readOnlyHint: false, destructiveHint: true },
153
+ }, async ({ nameOrId, budgetMsat }) => {
154
+ // The serving process re-reads the grant before each request, so the
155
+ // new budget applies to the very next payment.
156
+ const grant = refillGrant(grantsFile, identity(), nameOrId, budgetMsat);
157
+ return text({ ok: true, ...shown(grant) });
158
+ });
159
+ if (issuing)
160
+ server.registerTool('wallet-serve', {
161
+ description: 'Start or stop answering NIP-47 for the connections issued above. While it runs, holders of those URIs can call the methods their grant allows, against this identity\'s wallet. It answers only while this process is alive.',
162
+ inputSchema: { action: z.enum(['start', 'stop', 'status']).default('status') },
163
+ annotations: { readOnlyHint: false },
164
+ }, async ({ action }) => {
165
+ if (action === 'stop') {
166
+ stopService();
167
+ return text({ ok: true, serving: [], message: 'Stopped. Nothing is answered until it runs again.' });
168
+ }
169
+ if (action === 'start') {
170
+ const live = read().filter((grant) => !grant.revokedAt);
171
+ if (!live.length)
172
+ throw new Error('Nothing to serve - `wallet-grant` issues a connection first.');
173
+ for (const grant of live)
174
+ await startServing(grant);
175
+ return text({ ok: true, serving: service?.serving() ?? [], grants: live.map(shown) });
176
+ }
177
+ return text({ ok: true, running: service !== null, serving: service?.serving() ?? [] });
178
+ });
189
179
  }
@@ -1,8 +1,10 @@
1
1
  import type { NwcTransport } from '@forgesworn/nwc-kit';
2
2
  import { type ServiceWallet } from './service.js';
3
+ import { type PaymentGuard } from '../zap/payment-guard.js';
3
4
  export interface UpstreamOptions {
4
5
  uri: string;
5
6
  transport?: NwcTransport;
6
7
  alias?: string;
8
+ guard?: PaymentGuard;
7
9
  }
8
10
  export declare function upstreamWallet(options: UpstreamOptions): ServiceWallet;
@@ -1,6 +1,7 @@
1
- import { NwcClient } from '@forgesworn/nwc-kit';
1
+ import { NwcClient, NwcError } from '@forgesworn/nwc-kit';
2
2
  import { tryDecodeBolt11, verifyPreimage } from 'farrier-kit';
3
3
  import { PaymentNotSentError, ServiceError } from './service.js';
4
+ import { PaymentLimitError } from '../zap/payment-guard.js';
4
5
  async function withClient(options, run) {
5
6
  const client = new NwcClient(options.uri, { ...(options.transport ? { transport: options.transport } : {}) });
6
7
  try {
@@ -49,23 +50,58 @@ export function upstreamWallet(options) {
49
50
  };
50
51
  });
51
52
  },
52
- async payInvoice({ invoice }) {
53
+ async payInvoice({ invoice, amountMsat }) {
53
54
  const decoded = tryDecodeBolt11(invoice);
54
55
  if (!decoded)
55
56
  throw new PaymentNotSentError('That is not a decodable BOLT-11 invoice.');
57
+ if (decoded.amountMsats === null || Number(decoded.amountMsats) !== amountMsat) {
58
+ throw new PaymentNotSentError('Only invoices that state the amount being charged are paid.');
59
+ }
60
+ const paymentHash = decoded.paymentHashHex;
61
+ const guard = options.guard;
56
62
  return withClient(options, async (client) => {
57
63
  const capabilities = await client.connect();
58
64
  if (!capabilities.methods.includes('pay_invoice')) {
59
65
  throw new PaymentNotSentError('The wallet behind this service cannot pay invoices.');
60
66
  }
61
- const result = await client.payInvoice({ invoice });
62
- if (!result.preimage || !verifyPreimage(result.preimage, decoded.paymentHashHex)) {
67
+ try {
68
+ guard?.reserve(paymentHash, amountMsat);
69
+ }
70
+ catch (err) {
71
+ if (err instanceof PaymentLimitError)
72
+ throw new PaymentNotSentError(err.message);
73
+ throw err;
74
+ }
75
+ let result;
76
+ try {
77
+ result = await client.payInvoice({ invoice });
78
+ }
79
+ catch (err) {
80
+ // An authenticated refusal from the wallet is definitive, exactly
81
+ // as zap-send treats it: nothing left, so the budget and bray's
82
+ // allowance are given back. Its text is not passed on - "not
83
+ // enough funds" tells a scoped connection about a wallet it has
84
+ // no business knowing about.
85
+ if (err instanceof NwcError && err.code === 'WALLET_ERROR') {
86
+ guard?.release(paymentHash);
87
+ throw new PaymentNotSentError('The wallet behind this service declined the payment; nothing was sent.');
88
+ }
89
+ // Anything else may have happened after the payment went out.
90
+ guard?.markUnknown(paymentHash);
91
+ throw new ServiceError('OTHER', 'The wallet behind this service did not confirm the payment; it may or may not have been sent.');
92
+ }
93
+ if (!result.preimage || !verifyPreimage(result.preimage, paymentHash)) {
63
94
  // The wallet says it paid and cannot prove it. Not a refusal -
64
95
  // something may well have gone out - so the budget keeps the
65
96
  // charge and the caller is told the proof is missing.
97
+ guard?.markUnknown(paymentHash);
66
98
  throw new ServiceError('OTHER', 'The wallet claims payment but its preimage does not settle that invoice.');
67
99
  }
68
- return { preimage: result.preimage, feesPaidMsat: Number(result.fees_paid ?? 0) };
100
+ guard?.settle(paymentHash, result.fees_paid);
101
+ return {
102
+ preimage: result.preimage,
103
+ ...(result.fees_paid === undefined ? {} : { feesPaidMsat: Number(result.fees_paid) }),
104
+ };
69
105
  });
70
106
  },
71
107
  async lookupInvoice(query) {
@@ -0,0 +1,16 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export type HumanApproval = 'approved' | 'declined' | 'unavailable';
3
+ export declare function clientCanElicit(server: McpServer): boolean;
4
+ /**
5
+ * Ask the person using the MCP client to approve a payment.
6
+ *
7
+ * Returns `unavailable` when the client cannot be asked. Any failure to get
8
+ * a clear yes once asking is possible is a `declined`: a payment is never
9
+ * made because a prompt went wrong.
10
+ */
11
+ export declare function askHumanToApprovePayment(server: McpServer, details: {
12
+ amountMsats: number;
13
+ purpose: string;
14
+ description?: string;
15
+ paymentHash?: string;
16
+ }): Promise<HumanApproval>;
@@ -0,0 +1,58 @@
1
+ function elicitingServer(server) {
2
+ const inner = server.server;
3
+ if (!inner || typeof inner.getClientCapabilities !== 'function' || typeof inner.elicitInput !== 'function') {
4
+ return null;
5
+ }
6
+ return inner;
7
+ }
8
+ export function clientCanElicit(server) {
9
+ const inner = elicitingServer(server);
10
+ if (!inner)
11
+ return false;
12
+ const elicitation = inner.getClientCapabilities()?.elicitation;
13
+ if (!elicitation || typeof elicitation !== 'object')
14
+ return false;
15
+ // An empty capability object means form elicitation (the pre-modes shape).
16
+ return 'form' in elicitation ? Boolean(elicitation.form) : Object.keys(elicitation).length === 0;
17
+ }
18
+ /**
19
+ * Ask the person using the MCP client to approve a payment.
20
+ *
21
+ * Returns `unavailable` when the client cannot be asked. Any failure to get
22
+ * a clear yes once asking is possible is a `declined`: a payment is never
23
+ * made because a prompt went wrong.
24
+ */
25
+ export async function askHumanToApprovePayment(server, details) {
26
+ if (!clientCanElicit(server))
27
+ return 'unavailable';
28
+ const inner = elicitingServer(server);
29
+ const sats = details.amountMsats / 1000;
30
+ const lines = [
31
+ `bray wants to spend ${details.amountMsats} msat (${sats} sats) to ${details.purpose}.`,
32
+ ...(details.description ? [`Invoice description: ${details.description}`] : []),
33
+ ...(details.paymentHash ? [`Payment hash: ${details.paymentHash}`] : []),
34
+ 'Approve only if you asked for this payment.',
35
+ ];
36
+ try {
37
+ const result = await inner.elicitInput({
38
+ mode: 'form',
39
+ message: lines.join('\n'),
40
+ requestedSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ approve: {
44
+ type: 'boolean',
45
+ title: 'Approve this payment',
46
+ description: `Pay ${details.amountMsats} msat`,
47
+ default: false,
48
+ },
49
+ },
50
+ required: ['approve'],
51
+ },
52
+ });
53
+ return result.action === 'accept' && result.content?.approve === true ? 'approved' : 'declined';
54
+ }
55
+ catch {
56
+ return 'declined';
57
+ }
58
+ }
@@ -1,6 +1,7 @@
1
1
  import type { GetBalanceResult, NwcRequestOptions, NwcTransaction, NwcTransport, PayInvoiceResult } from '@forgesworn/nwc-kit';
2
2
  import type { SigningContext } from '../signing-context.js';
3
3
  import type { RelayPool } from '../relay-pool.js';
4
+ import type { PaymentGuard } from './payment-guard.js';
4
5
  /**
5
6
  * Load the wallets map from the JSON file. Returns empty map if file missing.
6
7
  *
@@ -98,6 +99,7 @@ export declare function handleZapDecode(bolt11: string): {
98
99
  amountMsats?: number;
99
100
  description?: string;
100
101
  expiry?: number;
102
+ paymentHash?: string;
101
103
  };
102
104
  interface NwcOperationOptions extends NwcRequestOptions {
103
105
  /** Injectable for deterministic tests and specialised relay transports. */
@@ -119,9 +121,16 @@ export declare class ZapPaymentOutcomeUnknownError extends Error {
119
121
  readonly code = "PAYMENT_OUTCOME_UNKNOWN";
120
122
  constructor(paymentHash: string);
121
123
  }
122
- /** Pay a BOLT-11 invoice and return only after the wallet proves settlement. */
124
+ /**
125
+ * Pay a BOLT-11 invoice and return only after the wallet proves settlement.
126
+ *
127
+ * With a `guard`, bray's own per-payment and rolling daily ceilings are
128
+ * checked and the attempt is recorded before anything is sent. Every bray
129
+ * surface that spends passes one; library callers should too.
130
+ */
123
131
  export declare function handleZapSend(_ctx: SigningContext, _pool: RelayPool, args: {
124
132
  invoice: string;
133
+ guard?: PaymentGuard;
125
134
  } & NwcArgs): Promise<ZapPaymentResult>;
126
135
  /** Return the wallet's confirmed balance in millisatoshis. */
127
136
  export declare function handleZapBalance(_ctx: SigningContext, _pool: RelayPool, args: NwcArgs): Promise<GetBalanceResult>;
@@ -201,6 +201,7 @@ export function handleZapDecode(bolt11) {
201
201
  ...(decoded.amountMsats !== null ? { amountMsats: Number(decoded.amountMsats) } : {}),
202
202
  ...(decoded.description !== null ? { description: decoded.description } : {}),
203
203
  expiry: decoded.expirySeconds,
204
+ paymentHash: decoded.paymentHashHex,
204
205
  };
205
206
  }
206
207
  /**
@@ -237,7 +238,42 @@ async function withClient(nwcUri, transport, operation) {
237
238
  client.close();
238
239
  }
239
240
  }
240
- /** Pay a BOLT-11 invoice and return only after the wallet proves settlement. */
241
+ /**
242
+ * An earlier attempt at this invoice ended without an answer. Ask the wallet
243
+ * what happened before anything is sent again: settled means it was paid
244
+ * (and the proof is returned), failed or expired means it may be retried,
245
+ * and anything else leaves it unknown and refused.
246
+ */
247
+ async function reconcileUnknownPayment(uri, paymentHash, guard, args) {
248
+ let found;
249
+ try {
250
+ found = await withClient(uri, args.transport, (client) => client.lookupInvoice({ payment_hash: paymentHash }, requestOptions(args)));
251
+ }
252
+ catch {
253
+ throw new ZapPaymentOutcomeUnknownError(paymentHash);
254
+ }
255
+ if (typeof found.preimage === 'string' && verifyPreimage(found.preimage, paymentHash)) {
256
+ guard.settle(paymentHash, found.fees_paid);
257
+ return {
258
+ preimage: found.preimage,
259
+ ...(found.fees_paid === undefined ? {} : { fees_paid: found.fees_paid }),
260
+ paymentHash,
261
+ verified: true,
262
+ };
263
+ }
264
+ if (found.state === 'failed' || found.state === 'expired') {
265
+ guard.release(paymentHash);
266
+ return null;
267
+ }
268
+ throw new ZapPaymentOutcomeUnknownError(paymentHash);
269
+ }
270
+ /**
271
+ * Pay a BOLT-11 invoice and return only after the wallet proves settlement.
272
+ *
273
+ * With a `guard`, bray's own per-payment and rolling daily ceilings are
274
+ * checked and the attempt is recorded before anything is sent. Every bray
275
+ * surface that spends passes one; library callers should too.
276
+ */
241
277
  export async function handleZapSend(_ctx, _pool, args) {
242
278
  const uri = requireWallet(args.nwcUri, 'enable payments');
243
279
  const decoded = tryDecodeBolt11(args.invoice);
@@ -249,6 +285,15 @@ export async function handleZapSend(_ctx, _pool, args) {
249
285
  if (decoded.amountMsats > BigInt(Number.MAX_SAFE_INTEGER)) {
250
286
  throw new Error('BOLT-11 amount exceeds JavaScript safe integer range');
251
287
  }
288
+ const paymentHash = decoded.paymentHashHex;
289
+ const guard = args.guard;
290
+ const prior = guard?.status(paymentHash);
291
+ if (guard && (prior === 'pending' || prior === 'unknown')) {
292
+ const settled = await reconcileUnknownPayment(uri, paymentHash, guard, args);
293
+ if (settled)
294
+ return settled;
295
+ }
296
+ guard?.reserve(paymentHash, Number(decoded.amountMsats));
252
297
  let paid;
253
298
  try {
254
299
  paid = await withClient(uri, args.transport, (client) => client.payInvoice({ invoice: args.invoice }, requestOptions(args)));
@@ -256,14 +301,19 @@ export async function handleZapSend(_ctx, _pool, args) {
256
301
  catch (error) {
257
302
  // An authenticated wallet rejection is definitive. Transport, timeout,
258
303
  // abort, and malformed-response failures can happen after submission.
259
- if (error instanceof NwcError && error.code === 'WALLET_ERROR')
304
+ if (error instanceof NwcError && error.code === 'WALLET_ERROR') {
305
+ guard?.release(paymentHash);
260
306
  throw error;
261
- throw new ZapPaymentOutcomeUnknownError(decoded.paymentHashHex);
307
+ }
308
+ guard?.markUnknown(paymentHash);
309
+ throw new ZapPaymentOutcomeUnknownError(paymentHash);
262
310
  }
263
- if (!verifyPreimage(paid.preimage, decoded.paymentHashHex)) {
264
- throw new ZapPaymentOutcomeUnknownError(decoded.paymentHashHex);
311
+ if (!verifyPreimage(paid.preimage, paymentHash)) {
312
+ guard?.markUnknown(paymentHash);
313
+ throw new ZapPaymentOutcomeUnknownError(paymentHash);
265
314
  }
266
- return { ...paid, paymentHash: decoded.paymentHashHex, verified: true };
315
+ guard?.settle(paymentHash, paid.fees_paid);
316
+ return { ...paid, paymentHash, verified: true };
267
317
  }
268
318
  /** Return the wallet's confirmed balance in millisatoshis. */
269
319
  export async function handleZapBalance(_ctx, _pool, args) {
@@ -1,2 +1,3 @@
1
1
  export * from './handlers.js';
2
2
  export * from './nwc-file.js';
3
+ export * from './payment-guard.js';
package/dist/zap/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './handlers.js';
2
2
  export * from './nwc-file.js';
3
+ export * from './payment-guard.js';
@@ -0,0 +1,59 @@
1
+ /** 5,000 sats. */
2
+ export declare const DEFAULT_MAX_PAYMENT_MSAT = 5000000;
3
+ /** 20,000 sats in any rolling 24 hours. */
4
+ export declare const DEFAULT_MAX_DAILY_MSAT = 20000000;
5
+ /**
6
+ * Routing fees are unknown until a payment lands, so a payment reserves a
7
+ * margin for them: 1% of the amount, never less than 1 sat. The actual fee
8
+ * replaces the margin once the wallet reports it.
9
+ */
10
+ export declare function feeReserveMsat(amountMsat: number): number;
11
+ export interface PaymentLimits {
12
+ maxPaymentMsat: number;
13
+ maxDailyMsat: number;
14
+ }
15
+ /** Read `BRAY_MAX_PAYMENT_MSAT` and `BRAY_MAX_DAILY_MSAT`, falling back to the defaults. */
16
+ export declare function paymentLimitsFromEnv(env?: NodeJS.ProcessEnv): PaymentLimits;
17
+ /** bray's state directory: `BRAY_HOME`, else `$XDG_CONFIG_HOME/bray`, else `~/.config/bray`. */
18
+ export declare function brayStateDir(env?: NodeJS.ProcessEnv): string;
19
+ export declare function defaultLedgerPath(env?: NodeJS.ProcessEnv): string;
20
+ /** A payment bray refused before anything was sent. */
21
+ export declare class PaymentLimitError extends Error {
22
+ readonly code = "PAYMENT_LIMIT";
23
+ constructor(message: string);
24
+ }
25
+ export type PaymentStatus = 'pending' | 'paid' | 'failed' | 'unknown';
26
+ export interface PaymentGuardOptions {
27
+ path?: string;
28
+ limits?: PaymentLimits;
29
+ now?: () => number;
30
+ }
31
+ export declare class PaymentGuard {
32
+ #private;
33
+ readonly path: string;
34
+ readonly limits: PaymentLimits;
35
+ constructor(options?: PaymentGuardOptions);
36
+ /**
37
+ * Throw if the caps would refuse a payment, without recording anything.
38
+ * An earlier attempt with an unknown outcome is left to the payment
39
+ * itself, which looks it up before deciding.
40
+ */
41
+ check(paymentHash: string, amountMsat: number): void;
42
+ /**
43
+ * Record a payment as about to be attempted, charging the amount plus a
44
+ * fee reserve against the daily total. Written before the attempt, so a
45
+ * crash mid-payment leaves the allowance spent rather than free.
46
+ */
47
+ reserve(paymentHash: string, amountMsat: number): void;
48
+ /** The payment settled: charge the amount and the fee actually paid. */
49
+ settle(paymentHash: string, feesPaidMsat: number | undefined): void;
50
+ /** The wallet refused before anything left: give the allowance back. */
51
+ release(paymentHash: string): void;
52
+ /** Something may have gone out: keep the whole reservation charged. */
53
+ markUnknown(paymentHash: string): void;
54
+ status(paymentHash: string): PaymentStatus | undefined;
55
+ /** Total counted against the rolling 24-hour allowance. */
56
+ spentTodayMsat(): number;
57
+ }
58
+ /** The guard every spending surface uses unless a test hands in its own. */
59
+ export declare function defaultPaymentGuard(): PaymentGuard;