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,31 @@
1
- import type { Grant } from './service.js';
1
+ import { type Grant, type GrantStore } from './service.js';
2
2
  export interface GrantsFile {
3
3
  version: 1;
4
4
  grants: Record<string, Grant[]>;
5
5
  }
6
6
  export declare function defaultGrantsFile(): string;
7
7
  export declare function loadGrants(path: string, pubkeyHex: string): Grant[];
8
+ /**
9
+ * Change one identity's grants as they stand on disk now, under a lock.
10
+ * If `mutate` throws, nothing is written.
11
+ */
12
+ export declare function updateGrants<T>(path: string, pubkeyHex: string, mutate: (grants: Grant[]) => T): T;
13
+ /** Replace one identity's grants wholesale. Prefer `updateGrants`. */
8
14
  export declare function saveGrants(path: string, pubkeyHex: string, grants: Grant[]): void;
15
+ /** Claim the right to serve the grants in `path`, or say who holds it. */
16
+ export declare function acquireServeLock(path: string): void;
17
+ export declare function releaseServeLock(path: string): void;
18
+ /** A `GrantStore` over the grants file, for the process that serves them. */
19
+ export declare function fileGrantStore(path: string, pubkeyHex: string): GrantStore;
20
+ /** Revoke a connection on disk. Every process sees it before its next request. */
21
+ export declare function revokeGrant(path: string, pubkeyHex: string, nameOrId: string, now?: number): Grant;
22
+ /** Reset a spending connection's budget on disk, optionally to a new figure. */
23
+ export declare function refillGrant(path: string, pubkeyHex: string, nameOrId: string, budgetMsat?: number): Grant;
24
+ /**
25
+ * Write a grant's connection URI to its own 0600 file and return the path.
26
+ *
27
+ * The URI is a bearer secret: whoever holds it can do what the grant
28
+ * allows. It goes to a file for the operator to hand over, never into a
29
+ * tool result, where it would sit in a model's context and transcript.
30
+ */
31
+ export declare function writeGrantUriFile(grantsFile: string, grant: Grant): string;
@@ -1,21 +1,30 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { randomUUID } from 'node:crypto';
4
+ import { homedir } from 'node:os';
5
+ import { grantUri } from './service.js';
6
+ import { withFileLock } from '../file-lock.js';
5
7
  // Where the grants live.
6
8
  //
7
9
  // Each one carries two secret keys: the service key it answers under, and
8
10
  // the client key inside the URI that was handed out. Both spend, so the
9
11
  // file is 0600 and written atomically - a half-written grants file after a
10
12
  // crash would either lose a capability or leave one nobody can revoke.
13
+ //
14
+ // The file, not any process's memory, is the record. Several bray
15
+ // processes can share it (an MCP session, a CLI, a second client), so
16
+ // every change is a locked read-modify-write of the current file, and the
17
+ // process that serves grants re-reads a grant before each request. A
18
+ // revocation or refill made anywhere is therefore seen everywhere, and no
19
+ // process can undo one by writing back what it happened to remember.
11
20
  const MAX_FILE_BYTES = 1_048_576;
12
21
  const MAX_GRANTS = 128;
13
22
  export function defaultGrantsFile() {
14
23
  return join(process.env.BRAY_HOME ?? join(homedir(), '.config', 'bray'), 'wallet-grants.json');
15
24
  }
16
- export function loadGrants(path, pubkeyHex) {
25
+ function readFile(path) {
17
26
  if (!existsSync(path))
18
- return [];
27
+ return { version: 1, grants: {} };
19
28
  const raw = readFileSync(path, 'utf8');
20
29
  if (raw.length > MAX_FILE_BYTES)
21
30
  throw new Error('The grants file is implausibly large; refusing to read it.');
@@ -26,31 +35,174 @@ export function loadGrants(path, pubkeyHex) {
26
35
  catch {
27
36
  throw new Error('The grants file is not readable JSON.');
28
37
  }
29
- if (parsed?.version !== 1 || typeof parsed.grants !== 'object')
30
- return [];
31
- const held = parsed.grants[pubkeyHex];
38
+ if (parsed?.version !== 1 || typeof parsed.grants !== 'object' || parsed.grants === null) {
39
+ return { version: 1, grants: {} };
40
+ }
41
+ return parsed;
42
+ }
43
+ function writeFile(path, file) {
44
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
45
+ const temporary = `${path}.${randomUUID()}.tmp`;
46
+ writeFileSync(temporary, JSON.stringify(file, null, 2), { mode: 0o600 });
47
+ chmodSync(temporary, 0o600);
48
+ renameSync(temporary, path);
49
+ chmodSync(path, 0o600);
50
+ }
51
+ export function loadGrants(path, pubkeyHex) {
52
+ const held = readFile(path).grants[pubkeyHex];
32
53
  return Array.isArray(held) ? held : [];
33
54
  }
55
+ /**
56
+ * Change one identity's grants as they stand on disk now, under a lock.
57
+ * If `mutate` throws, nothing is written.
58
+ */
59
+ export function updateGrants(path, pubkeyHex, mutate) {
60
+ return withFileLock(path, () => {
61
+ const file = readFile(path);
62
+ const grants = Array.isArray(file.grants[pubkeyHex]) ? file.grants[pubkeyHex] : [];
63
+ const result = mutate(grants);
64
+ if (grants.length > MAX_GRANTS)
65
+ throw new Error(`No more than ${MAX_GRANTS} connections per identity.`);
66
+ file.grants[pubkeyHex] = grants;
67
+ writeFile(path, file);
68
+ return result;
69
+ });
70
+ }
71
+ /** Replace one identity's grants wholesale. Prefer `updateGrants`. */
34
72
  export function saveGrants(path, pubkeyHex, grants) {
35
73
  if (grants.length > MAX_GRANTS)
36
74
  throw new Error(`No more than ${MAX_GRANTS} connections per identity.`);
37
- let file = { version: 1, grants: {} };
38
- if (existsSync(path)) {
75
+ updateGrants(path, pubkeyHex, (held) => {
76
+ held.splice(0, held.length, ...grants);
77
+ });
78
+ }
79
+ // Only one process answers for the grants at a time. Two serving the same
80
+ // connection would each charge their own copy of its budget and each
81
+ // answer the same request.
82
+ function servingPid(lockPath) {
83
+ try {
84
+ const pid = Number.parseInt(readFileSync(lockPath, 'utf8'), 10);
85
+ if (!Number.isSafeInteger(pid) || pid <= 0)
86
+ return null;
87
+ if (pid === process.pid)
88
+ return pid;
39
89
  try {
40
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
41
- if (parsed?.version === 1 && typeof parsed.grants === 'object')
42
- file = parsed;
90
+ process.kill(pid, 0);
91
+ return pid;
43
92
  }
44
- catch {
45
- // an unreadable file is replaced rather than appended to; the
46
- // alternative is refusing to ever write again
93
+ catch (err) {
94
+ return err.code === 'EPERM' ? pid : null;
47
95
  }
48
96
  }
49
- file.grants[pubkeyHex] = grants;
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Claim the right to serve the grants in `path`, or say who holds it. */
102
+ export function acquireServeLock(path) {
103
+ const lockPath = `${path}.serving`;
50
104
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
51
- const temporary = `${path}.${randomUUID()}.tmp`;
52
- writeFileSync(temporary, JSON.stringify(file, null, 2), { mode: 0o600 });
53
- chmodSync(temporary, 0o600);
54
- renameSync(temporary, path);
105
+ for (let attempt = 0; attempt < 3; attempt++) {
106
+ try {
107
+ const descriptor = openSync(lockPath, 'wx', 0o600);
108
+ try {
109
+ writeSync(descriptor, String(process.pid));
110
+ }
111
+ finally {
112
+ closeSync(descriptor);
113
+ }
114
+ return;
115
+ }
116
+ catch (err) {
117
+ if (err.code !== 'EEXIST')
118
+ throw err;
119
+ const holder = servingPid(lockPath);
120
+ if (holder === process.pid)
121
+ return;
122
+ if (holder !== null) {
123
+ throw new Error(`Another bray process (pid ${holder}) is already serving these wallet connections. Stop it first; two processes serving the same connections would each count their own budget.`);
124
+ }
125
+ // The holder is gone: its claim goes with it.
126
+ try {
127
+ unlinkSync(lockPath);
128
+ }
129
+ catch { /* someone else cleared it first */ }
130
+ }
131
+ }
132
+ throw new Error('Could not claim the wallet-service lock.');
133
+ }
134
+ export function releaseServeLock(path) {
135
+ const lockPath = `${path}.serving`;
136
+ if (servingPid(lockPath) === process.pid) {
137
+ try {
138
+ unlinkSync(lockPath);
139
+ }
140
+ catch { /* already gone */ }
141
+ }
142
+ }
143
+ /** A `GrantStore` over the grants file, for the process that serves them. */
144
+ export function fileGrantStore(path, pubkeyHex) {
145
+ return {
146
+ read: (grantId) => loadGrants(path, pubkeyHex).find((grant) => grant.id === grantId),
147
+ update: (grantId, mutate) => updateGrants(path, pubkeyHex, (grants) => {
148
+ const grant = grants.find((held) => held.id === grantId);
149
+ if (!grant)
150
+ return undefined;
151
+ mutate(grant);
152
+ return structuredClone(grant);
153
+ }),
154
+ };
155
+ }
156
+ function find(grants, nameOrId, byPrefix) {
157
+ const wanted = nameOrId.trim().toLowerCase();
158
+ return (grants.find((held) => held.id === wanted) ??
159
+ grants.find((held) => held.name.toLowerCase() === wanted) ??
160
+ (byPrefix ? grants.find((held) => held.id.startsWith(wanted)) : undefined));
161
+ }
162
+ /** Revoke a connection on disk. Every process sees it before its next request. */
163
+ export function revokeGrant(path, pubkeyHex, nameOrId, now = Date.now()) {
164
+ return updateGrants(path, pubkeyHex, (grants) => {
165
+ const grant = find(grants, nameOrId, true);
166
+ if (!grant)
167
+ throw new Error(`No connection here called ${nameOrId}.`);
168
+ grant.revokedAt ??= now;
169
+ return structuredClone(grant);
170
+ });
171
+ }
172
+ /** Reset a spending connection's budget on disk, optionally to a new figure. */
173
+ export function refillGrant(path, pubkeyHex, nameOrId, budgetMsat) {
174
+ return updateGrants(path, pubkeyHex, (grants) => {
175
+ const grant = find(grants, nameOrId, false);
176
+ if (!grant)
177
+ throw new Error(`No connection here called ${nameOrId}.`);
178
+ if (grant.revokedAt)
179
+ throw new Error('That connection is revoked - issue a new one.');
180
+ if (grant.budgetMsat === undefined)
181
+ throw new Error('That connection cannot spend, so it has no budget.');
182
+ if (budgetMsat !== undefined) {
183
+ if (grant.maxPaymentMsat !== undefined && grant.maxPaymentMsat > budgetMsat) {
184
+ throw new Error('The per-payment ceiling is above that budget.');
185
+ }
186
+ grant.budgetMsat = budgetMsat;
187
+ }
188
+ grant.spentMsat = 0;
189
+ return structuredClone(grant);
190
+ });
191
+ }
192
+ /**
193
+ * Write a grant's connection URI to its own 0600 file and return the path.
194
+ *
195
+ * The URI is a bearer secret: whoever holds it can do what the grant
196
+ * allows. It goes to a file for the operator to hand over, never into a
197
+ * tool result, where it would sit in a model's context and transcript.
198
+ */
199
+ export function writeGrantUriFile(grantsFile, grant) {
200
+ const directory = join(dirname(grantsFile), 'wallet-connections');
201
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
202
+ chmodSync(directory, 0o700);
203
+ const slug = grant.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'connection';
204
+ const path = join(directory, `${slug}-${grant.id}.nwc`);
205
+ writeFileSync(path, `${grantUri(grant)}\n`, { mode: 0o600, flag: 'wx' });
55
206
  chmodSync(path, 0o600);
207
+ return path;
56
208
  }
@@ -2,7 +2,7 @@ import type { Event as NostrEvent, Filter } from 'nostr-tools';
2
2
  export declare const NWC_INFO_KIND = 13194;
3
3
  export declare const NWC_REQUEST_KIND = 23194;
4
4
  export declare const NWC_RESPONSE_KIND = 23195;
5
- export declare const DEFAULT_METHODS: readonly ["get_info", "make_invoice", "lookup_invoice"];
5
+ export declare const DEFAULT_METHODS: readonly ["get_info", "make_invoice"];
6
6
  export declare const SUPPORTED_METHODS: readonly ["get_info", "get_balance", "make_invoice", "lookup_invoice", "pay_invoice"];
7
7
  export declare const SPENDING_METHODS: readonly string[];
8
8
  export declare class ServiceError extends Error {
@@ -36,7 +36,7 @@ export interface ServiceWallet {
36
36
  amountMsat: number;
37
37
  }): Promise<{
38
38
  preimage: string;
39
- feesPaidMsat: number;
39
+ feesPaidMsat?: number;
40
40
  }>;
41
41
  lookupInvoice(query: {
42
42
  paymentHash?: string;
@@ -60,6 +60,7 @@ export interface Grant {
60
60
  spentMsat: number;
61
61
  maxPaymentMsat?: number;
62
62
  seen?: string[];
63
+ hashes?: string[];
63
64
  createdAt: number;
64
65
  lastUsedAt?: number;
65
66
  revokedAt?: number;
@@ -81,10 +82,15 @@ export declare function newGrant(input: {
81
82
  maxPaymentMsat?: number;
82
83
  }): Grant;
83
84
  export declare function infoEvent(grant: Grant): NostrEvent;
85
+ export interface GrantStore {
86
+ read(grantId: string): Grant | undefined;
87
+ update(grantId: string, mutate: (grant: Grant) => void): Grant | undefined;
88
+ }
84
89
  export interface ServiceOptions {
85
90
  wallet: ServiceWallet;
86
91
  transport: ServiceTransport;
87
- persist: () => Promise<void>;
92
+ store?: GrantStore;
93
+ persist?: () => Promise<void>;
88
94
  log?: (message: string) => void;
89
95
  now?: () => number;
90
96
  }
@@ -1,6 +1,7 @@
1
1
  import { finalizeEvent, getPublicKey, verifyEvent, nip44 } from 'nostr-tools';
2
2
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
3
3
  import { tryDecodeBolt11 } from 'farrier-kit';
4
+ import { feeReserveMsat } from '../zap/payment-guard.js';
4
5
  // A NIP-47 wallet service: the server side of Nostr Wallet Connect.
5
6
  //
6
7
  // bray already speaks NWC as a client - `zap-send` spends through a wallet
@@ -29,7 +30,10 @@ import { tryDecodeBolt11 } from 'farrier-kit';
29
30
  export const NWC_INFO_KIND = 13194;
30
31
  export const NWC_REQUEST_KIND = 23194;
31
32
  export const NWC_RESPONSE_KIND = 23195;
32
- export const DEFAULT_METHODS = ['get_info', 'make_invoice', 'lookup_invoice'];
33
+ // lookup_invoice is not a default: even scoped to this connection's own
34
+ // invoices it is one more thing to hand out, and a connection that only
35
+ // issues invoices has no need to read them back.
36
+ export const DEFAULT_METHODS = ['get_info', 'make_invoice'];
33
37
  export const SUPPORTED_METHODS = [
34
38
  'get_info',
35
39
  'get_balance',
@@ -45,6 +49,8 @@ export const SPENDING_METHODS = ['pay_invoice'];
45
49
  const MAX_REQUEST_AGE_SECS = 300;
46
50
  const MAX_REQUEST_FUTURE_SECS = 60;
47
51
  const SEEN_LIMIT = 256;
52
+ // Payment hashes a connection may look up: the ones it created or paid.
53
+ const OWN_HASHES_LIMIT = 1024;
48
54
  export class ServiceError extends Error {
49
55
  code;
50
56
  constructor(code, message) {
@@ -162,6 +168,25 @@ function invoiceResult(view) {
162
168
  ...(view.feesPaidMsat === undefined ? {} : { fees_paid: view.feesPaidMsat }),
163
169
  };
164
170
  }
171
+ // The fields another process may change while this one serves.
172
+ function adopt(target, source) {
173
+ target.methods = source.methods;
174
+ target.spentMsat = source.spentMsat;
175
+ target.seen = source.seen;
176
+ target.hashes = source.hashes;
177
+ if (source.budgetMsat === undefined)
178
+ delete target.budgetMsat;
179
+ else
180
+ target.budgetMsat = source.budgetMsat;
181
+ if (source.maxPaymentMsat === undefined)
182
+ delete target.maxPaymentMsat;
183
+ else
184
+ target.maxPaymentMsat = source.maxPaymentMsat;
185
+ if (source.lastUsedAt !== undefined)
186
+ target.lastUsedAt = source.lastUsedAt;
187
+ if (source.revokedAt !== undefined)
188
+ target.revokedAt = source.revokedAt;
189
+ }
165
190
  export class WalletService {
166
191
  #opts;
167
192
  #now;
@@ -195,6 +220,37 @@ export class WalletService {
195
220
  for (const id of [...this.#stops.keys()])
196
221
  this.stop(id);
197
222
  }
223
+ // The grant as the store has it now. False when it is gone or revoked.
224
+ #refresh(grant) {
225
+ const store = this.#opts.store;
226
+ if (store) {
227
+ const current = store.read(grant.id);
228
+ if (!current)
229
+ return false;
230
+ adopt(grant, current);
231
+ }
232
+ return !grant.revokedAt;
233
+ }
234
+ // Change a grant where it is recorded, then here.
235
+ async #change(grant, mutate) {
236
+ const store = this.#opts.store;
237
+ if (store) {
238
+ const updated = store.update(grant.id, mutate);
239
+ if (!updated)
240
+ throw new ServiceError('UNAUTHORIZED', 'This connection no longer exists.');
241
+ adopt(grant, updated);
242
+ }
243
+ else {
244
+ mutate(grant);
245
+ }
246
+ await this.#opts.persist?.();
247
+ }
248
+ async #own(grant, paymentHash) {
249
+ const hash = paymentHash.toLowerCase();
250
+ await this.#change(grant, (held) => {
251
+ held.hashes = remembered(held.hashes, hash);
252
+ });
253
+ }
198
254
  // Every request on one connection runs after the last has finished. Two
199
255
  // pay_invoice requests arriving together must not both read the same
200
256
  // remaining budget and both decide there is room.
@@ -206,8 +262,15 @@ export class WalletService {
206
262
  }
207
263
  async #handle(grantId, event) {
208
264
  const grant = this.#grants.get(grantId);
209
- if (!grant || grant.revokedAt)
265
+ if (!grant)
266
+ return;
267
+ // Revoked by any process, or refilled, or spent from: the stored grant
268
+ // is the one that counts.
269
+ if (!this.#refresh(grant)) {
270
+ this.stop(grantId);
271
+ this.#opts.log?.(`stopped serving ${grant.name}: it has been revoked`);
210
272
  return;
273
+ }
211
274
  // Anyone can publish an event tagged at this pubkey - it is in the info
212
275
  // event, which is public. What keeps a stranger out is further down:
213
276
  // the conversation key is derived from THIS connection's client pubkey,
@@ -225,8 +288,7 @@ export class WalletService {
225
288
  const expiration = event.tags.find((tag) => tag[0] === 'expiration')?.[1];
226
289
  if (expiration && Number(expiration) < seconds)
227
290
  return;
228
- const seen = grant.seen ?? [];
229
- if (seen.includes(event.id)) {
291
+ if ((grant.seen ?? []).includes(event.id)) {
230
292
  this.#opts.log?.(`ignored a replayed request on ${grant.name}`);
231
293
  return;
232
294
  }
@@ -241,9 +303,11 @@ export class WalletService {
241
303
  const method = typeof parsed.method === 'string' ? parsed.method : '';
242
304
  const params = (parsed.params ?? {});
243
305
  const remember = async () => {
244
- grant.seen = [...seen, event.id].slice(-SEEN_LIMIT);
245
- grant.lastUsedAt = this.#now();
246
- await this.#opts.persist();
306
+ const at = this.#now();
307
+ await this.#change(grant, (held) => {
308
+ held.seen = [...(held.seen ?? []), event.id].slice(-SEEN_LIMIT);
309
+ held.lastUsedAt = at;
310
+ });
247
311
  };
248
312
  // A spending request is written down before it runs, so a crash between
249
313
  // the two leaves a request that will not be tried again rather than one
@@ -276,51 +340,82 @@ export class WalletService {
276
340
  case 'get_info':
277
341
  return { alias: wallet.alias(), network: 'mainnet', methods: grant.methods, notifications: [] };
278
342
  case 'get_balance':
279
- return { balance: await wallet.balanceMsat() };
343
+ // What this connection can spend, not what the wallet holds: the
344
+ // parent balance is the operator's business, and a connection with
345
+ // no budget can spend nothing.
346
+ return { balance: remainingBudgetMsat(grant) };
280
347
  case 'make_invoice': {
281
348
  const amountMsat = Number(params.amount);
282
349
  if (!Number.isSafeInteger(amountMsat) || amountMsat <= 0) {
283
350
  throw new ServiceError('OTHER', 'make_invoice needs an amount in milli-satoshis.');
284
351
  }
285
352
  const description = typeof params.description === 'string' ? params.description : undefined;
286
- return invoiceResult(await wallet.makeInvoice({ amountMsat, ...(description === undefined ? {} : { description }) }));
353
+ const view = await wallet.makeInvoice({ amountMsat, ...(description === undefined ? {} : { description }) });
354
+ await this.#own(grant, view.paymentHash);
355
+ return invoiceResult(view);
287
356
  }
288
357
  case 'lookup_invoice': {
289
- const paymentHash = typeof params.payment_hash === 'string' ? params.payment_hash : undefined;
290
- const invoice = typeof params.invoice === 'string' ? params.invoice : undefined;
291
- if (!paymentHash && !invoice) {
358
+ const asked = typeof params.payment_hash === 'string' ? params.payment_hash.trim().toLowerCase() : undefined;
359
+ const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : undefined;
360
+ if (!asked && !invoice) {
292
361
  throw new ServiceError('OTHER', 'lookup_invoice needs a payment_hash or an invoice.');
293
362
  }
294
- const view = await wallet.lookupInvoice({
295
- ...(paymentHash === undefined ? {} : { paymentHash }),
296
- ...(invoice === undefined ? {} : { invoice }),
297
- });
298
- if (!view)
299
- throw new ServiceError('NOT_FOUND', 'No invoice here by that name.');
363
+ const fromInvoice = invoice ? tryDecodeBolt11(invoice)?.paymentHashHex : undefined;
364
+ if (invoice && !fromInvoice)
365
+ throw new ServiceError('OTHER', 'That is not a decodable BOLT-11 invoice.');
366
+ if (asked && fromInvoice && asked !== fromInvoice) {
367
+ throw new ServiceError('OTHER', 'That payment_hash and invoice do not match.');
368
+ }
369
+ const paymentHash = (asked ?? fromInvoice);
370
+ // Not this connection's invoice: answered exactly as a missing one,
371
+ // so a connection cannot probe the wallet's history either.
372
+ const notFound = new ServiceError('NOT_FOUND', 'No invoice here by that name.');
373
+ if (!(grant.hashes ?? []).includes(paymentHash))
374
+ throw notFound;
375
+ const view = await wallet.lookupInvoice({ paymentHash });
376
+ if (!view || view.paymentHash.toLowerCase() !== paymentHash)
377
+ throw notFound;
300
378
  return invoiceResult(view);
301
379
  }
302
380
  case 'pay_invoice': {
303
381
  const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
304
382
  if (!invoice)
305
383
  throw new ServiceError('OTHER', 'pay_invoice needs an invoice.');
306
- const amountMsat = priceOf(invoice, params);
307
- charge(grant, amountMsat);
308
- // Spent BEFORE the attempt and persisted: a crash mid-payment must
309
- // leave a budget that has paid for it. The other order lets one
310
- // connection spend its grant twice by dying at the right moment.
311
- grant.spentMsat += amountMsat;
312
- await this.#opts.persist();
384
+ const { amountMsat, paymentHash } = priceOf(invoice, params);
385
+ // Checked and spent in one step against the stored grant, BEFORE the
386
+ // attempt: a crash mid-payment must leave a budget that has paid for
387
+ // it. The other order lets one connection spend its grant twice by
388
+ // dying at the right moment.
389
+ // Routing fees come out of the budget too. They are unknown until
390
+ // the payment lands, so a margin is reserved up front and swapped
391
+ // for the real fee afterwards.
392
+ const reserveMsat = feeReserveMsat(amountMsat);
393
+ await this.#change(grant, (held) => {
394
+ charge(held, amountMsat, reserveMsat);
395
+ held.spentMsat += amountMsat + reserveMsat;
396
+ held.hashes = remembered(held.hashes, paymentHash);
397
+ });
398
+ let paid;
313
399
  try {
314
- const paid = await this.#opts.wallet.payInvoice({ invoice, amountMsat });
315
- return { preimage: paid.preimage, fees_paid: paid.feesPaidMsat };
400
+ paid = await this.#opts.wallet.payInvoice({ invoice, amountMsat });
316
401
  }
317
402
  catch (err) {
318
403
  if (err instanceof PaymentNotSentError) {
319
- grant.spentMsat -= amountMsat;
320
- await this.#opts.persist();
404
+ await this.#change(grant, (held) => {
405
+ held.spentMsat = Math.max(0, held.spentMsat - amountMsat - reserveMsat);
406
+ });
321
407
  }
322
408
  throw err;
323
409
  }
410
+ const fee = paid.feesPaidMsat;
411
+ const known = fee !== undefined && Number.isSafeInteger(fee) && fee >= 0;
412
+ // A wallet that does not report its fee keeps the whole margin charged.
413
+ if (known) {
414
+ await this.#change(grant, (held) => {
415
+ held.spentMsat = Math.max(0, held.spentMsat - reserveMsat + fee);
416
+ });
417
+ }
418
+ return { preimage: paid.preimage, ...(known ? { fees_paid: fee } : {}) };
324
419
  }
325
420
  default:
326
421
  throw new ServiceError('NOT_IMPLEMENTED', `This service cannot answer ${method}.`);
@@ -330,6 +425,10 @@ export class WalletService {
330
425
  // What this payment costs the budget, read off the invoice itself rather
331
426
  // than taken from the request. A budget checked against a figure the payer
332
427
  // supplied is not a budget.
428
+ function remembered(hashes, paymentHash) {
429
+ const held = hashes ?? [];
430
+ return held.includes(paymentHash) ? held : [...held, paymentHash].slice(-OWN_HASHES_LIMIT);
431
+ }
333
432
  function priceOf(invoice, params) {
334
433
  const decoded = tryDecodeBolt11(invoice);
335
434
  if (!decoded)
@@ -343,15 +442,16 @@ function priceOf(invoice, params) {
343
442
  if (asked !== undefined && asked !== stated) {
344
443
  throw new PaymentNotSentError(`That invoice is for ${stated} msat, not the ${asked} msat asked for.`);
345
444
  }
346
- return stated;
445
+ return { amountMsat: stated, paymentHash: decoded.paymentHashHex };
347
446
  }
348
- if (asked === undefined)
349
- throw new PaymentNotSentError('That invoice states no amount - say how much to send.');
350
- return asked;
447
+ // An amountless invoice is refused, as zap-send refuses one. The wallet
448
+ // behind the service is asked to pay the invoice alone, so a figure
449
+ // checked against the budget here would not be the figure it sent.
450
+ throw new PaymentNotSentError('Invoices that state no amount are not paid through this connection.');
351
451
  }
352
452
  // Refuses before anything is attempted, and says which ceiling it hit: a
353
453
  // payer told only "no" tries again.
354
- function charge(grant, amountMsat) {
454
+ function charge(grant, amountMsat, reserveMsat) {
355
455
  if (grant.budgetMsat === undefined) {
356
456
  throw new ServiceError('RESTRICTED', 'This connection has no budget to spend from.');
357
457
  }
@@ -359,7 +459,7 @@ function charge(grant, amountMsat) {
359
459
  throw new ServiceError('QUOTA_EXCEEDED', `That is ${amountMsat} msat and this connection's ceiling for one payment is ${grant.maxPaymentMsat} msat.`);
360
460
  }
361
461
  const remaining = remainingBudgetMsat(grant);
362
- if (amountMsat > remaining) {
363
- throw new ServiceError('QUOTA_EXCEEDED', `That is ${amountMsat} msat and this connection has ${remaining} msat of its budget left.`);
462
+ if (amountMsat + reserveMsat > remaining) {
463
+ throw new ServiceError('QUOTA_EXCEEDED', `That is ${amountMsat} msat plus up to ${reserveMsat} msat reserved for fees, and this connection has ${remaining} msat of its budget left.`);
364
464
  }
365
465
  }
@@ -1,3 +1,12 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { ToolDeps } from '../identity/tools.js';
3
- export declare function registerWalletServiceTools(server: McpServer, deps: ToolDeps): void;
3
+ export interface WalletServiceToolOptions {
4
+ /**
5
+ * Register the tools that mint or widen spending authority: wallet-grant,
6
+ * wallet-refill and wallet-serve. Off by default; BRAY_WALLET_SERVICE=1
7
+ * turns it on. Listing and revoking connections are always available,
8
+ * because taking authority away should never need an opt-in.
9
+ */
10
+ issuing?: boolean;
11
+ }
12
+ export declare function registerWalletServiceTools(server: McpServer, deps: ToolDeps, options?: WalletServiceToolOptions): void;