nostr-bray 3.2.1 → 3.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nostr-bray
2
2
 
3
- **Trust-aware Nostr MCP for AI and humans.** 253 tools across 28 groups. Model-agnostic. Works with Claude, ChatGPT, Gemini, Cursor, Windsurf, or any MCP client.
3
+ **Trust-aware Nostr MCP for AI and humans.** 258 tools across 29 groups. Model-agnostic. Works with Claude, ChatGPT, Gemini, Cursor, Windsurf, or any MCP client.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/nostr-bray)](https://www.npmjs.com/package/nostr-bray)
6
6
  [![CI](https://github.com/forgesworn/bray/actions/workflows/ci.yml/badge.svg)](https://github.com/forgesworn/bray/actions/workflows/ci.yml)
@@ -85,6 +85,7 @@ For production use, prefer [Heartwood](https://github.com/forgesworn/heartwood)
85
85
  | **Blossom** | 10 | `blossom-upload`, `blossom-mirror`, `blossom-verify`, `blossom-repair` |
86
86
  | **Privacy** | 10 | `privacy-commit`, `privacy-prove-range`, `privacy-prove-age`, `privacy-publish-proof` |
87
87
  | **Zap** | 9 | `zap-send`, `zap-balance`, `zap-make-invoice`, `zap-decode` |
88
+ | **Wallet service** | 5 | `wallet-grant`, `wallet-grants`, `wallet-revoke`, `wallet-refill`, `wallet-serve` |
88
89
  | **Vault** | 9 | `vault-create`, `vault-encrypt`, `vault-share`, `vault-rotate` |
89
90
  | **Workflow** | 7 | `trust-score`, `verify-person`, `identity-setup`, `relay-health`, `feed-discover` |
90
91
  | **Signet** | 7 | `signet-badge`, `signet-vouch`, `signet-credentials`, `signet-challenge` |
@@ -224,6 +225,52 @@ Raw `NWC_URI` is refused; use `NWC_URI_FILE` or `wallet connect <nwc-file>` so
224
225
  the bearer credential never appears in a process environment, command argument
225
226
  or MCP tool argument.
226
227
 
228
+ ## Handing out a wallet connection
229
+
230
+ `zap-send` spends through the NWC URI you configured. That URI is an
231
+ unbounded capability over the wallet behind it: every method it supports, no
232
+ ceiling, until it is rotated. Handing one to an agent hands over everything.
233
+
234
+ `wallet-grant` issues connections over the same wallet that are narrower.
235
+
236
+ ```
237
+ wallet-grant { name: "research-agent", methods: ["get_info","pay_invoice"],
238
+ budgetMsat: 50000, maxPaymentMsat: 5000 }
239
+ wallet-grants # what exists, what it may do, what it has spent
240
+ wallet-revoke { nameOrId: "research-agent" }
241
+ wallet-refill { nameOrId: "research-agent", budgetMsat: 50000 }
242
+ wallet-serve { action: "start" }
243
+ ```
244
+
245
+ What a connection may do is an allowlist, and the default grants no spending
246
+ and does not disclose the balance: `get_info`, `make_invoice`,
247
+ `lookup_invoice`. Both of the others are opt-in per connection, and **a
248
+ connection that can spend must carry a budget** - there is no unlimited grant
249
+ to issue by accident.
250
+
251
+ The rules exist because a payment cannot be recalled:
252
+
253
+ - A request is answered **once**. Its id is persisted *before* the payment is
254
+ attempted, so a process that dies mid-payment comes back knowing not to try
255
+ again - a relay will hand the same signed request over twice.
256
+ - Requests on one connection are **serialised**, so two arriving together
257
+ cannot both read the same remaining budget.
258
+ - A request older than five minutes is not answered, whatever its own
259
+ `expiration` tag claims.
260
+ - Each connection has **its own service key**: two grants share nothing a
261
+ relay can correlate, and revoking one is deleting a key.
262
+ - The budget is charged what the **invoice** says, decoded here. A budget
263
+ checked against a figure the payer supplied is not a budget.
264
+ - A claimed payment whose preimage does not settle the invoice is refused
265
+ rather than passed on. Repeating an unproven claim would make this service
266
+ the one telling the lie.
267
+
268
+ Grants live in `wallet-grants.json` (0600, per identity), and they are
269
+ answered **only while `wallet-serve` is running** - which is to say, only for
270
+ as long as this process is alive. That is worth telling whoever you hand a URI
271
+ to: a connection that works while an MCP session is open is a different
272
+ promise from one that works overnight.
273
+
227
274
  ## CLI
228
275
 
229
276
  ```bash
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerTrustTools } from './trust/tools.js';
10
10
  import { registerRelayTools } from './relay/tools.js';
11
11
  import { registerRelayIntelligenceTools } from './relay/intelligence-tools.js';
12
12
  import { registerZapTools } from './zap/tools.js';
13
+ import { registerWalletServiceTools } from './wallet-service/tools.js';
13
14
  import { registerSafetyTools } from './safety/tools.js';
14
15
  import { registerUtilTools } from './util/tools.js';
15
16
  import { registerWorkflowTools } from './workflow/tools.js';
@@ -157,6 +158,7 @@ registerTrustTools(proxy, deps);
157
158
  registerRelayTools(proxy, deps);
158
159
  registerRelayIntelligenceTools(proxy, deps);
159
160
  registerZapTools(proxy, deps);
161
+ registerWalletServiceTools(proxy, deps);
160
162
  registerSafetyTools(proxy, deps);
161
163
  registerUtilTools(proxy, deps);
162
164
  registerWorkflowTools(proxy, {
@@ -0,0 +1,8 @@
1
+ import type { Grant } from './service.js';
2
+ export interface GrantsFile {
3
+ version: 1;
4
+ grants: Record<string, Grant[]>;
5
+ }
6
+ export declare function defaultGrantsFile(): string;
7
+ export declare function loadGrants(path: string, pubkeyHex: string): Grant[];
8
+ export declare function saveGrants(path: string, pubkeyHex: string, grants: Grant[]): void;
@@ -0,0 +1,56 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ // Where the grants live.
6
+ //
7
+ // Each one carries two secret keys: the service key it answers under, and
8
+ // the client key inside the URI that was handed out. Both spend, so the
9
+ // file is 0600 and written atomically - a half-written grants file after a
10
+ // crash would either lose a capability or leave one nobody can revoke.
11
+ const MAX_FILE_BYTES = 1_048_576;
12
+ const MAX_GRANTS = 128;
13
+ export function defaultGrantsFile() {
14
+ return join(process.env.BRAY_HOME ?? join(homedir(), '.config', 'bray'), 'wallet-grants.json');
15
+ }
16
+ export function loadGrants(path, pubkeyHex) {
17
+ if (!existsSync(path))
18
+ return [];
19
+ const raw = readFileSync(path, 'utf8');
20
+ if (raw.length > MAX_FILE_BYTES)
21
+ throw new Error('The grants file is implausibly large; refusing to read it.');
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch {
27
+ throw new Error('The grants file is not readable JSON.');
28
+ }
29
+ if (parsed?.version !== 1 || typeof parsed.grants !== 'object')
30
+ return [];
31
+ const held = parsed.grants[pubkeyHex];
32
+ return Array.isArray(held) ? held : [];
33
+ }
34
+ export function saveGrants(path, pubkeyHex, grants) {
35
+ if (grants.length > MAX_GRANTS)
36
+ throw new Error(`No more than ${MAX_GRANTS} connections per identity.`);
37
+ let file = { version: 1, grants: {} };
38
+ if (existsSync(path)) {
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
41
+ if (parsed?.version === 1 && typeof parsed.grants === 'object')
42
+ file = parsed;
43
+ }
44
+ catch {
45
+ // an unreadable file is replaced rather than appended to; the
46
+ // alternative is refusing to ever write again
47
+ }
48
+ }
49
+ file.grants[pubkeyHex] = grants;
50
+ 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);
55
+ chmodSync(path, 0o600);
56
+ }
@@ -0,0 +1,98 @@
1
+ import type { Event as NostrEvent, Filter } from 'nostr-tools';
2
+ export declare const NWC_INFO_KIND = 13194;
3
+ export declare const NWC_REQUEST_KIND = 23194;
4
+ export declare const NWC_RESPONSE_KIND = 23195;
5
+ export declare const DEFAULT_METHODS: readonly ["get_info", "make_invoice", "lookup_invoice"];
6
+ export declare const SUPPORTED_METHODS: readonly ["get_info", "get_balance", "make_invoice", "lookup_invoice", "pay_invoice"];
7
+ export declare const SPENDING_METHODS: readonly string[];
8
+ export declare class ServiceError extends Error {
9
+ code: string;
10
+ constructor(code: string, message: string);
11
+ }
12
+ export declare class PaymentNotSentError extends ServiceError {
13
+ constructor(message: string);
14
+ }
15
+ export interface ServiceInvoice {
16
+ type: 'incoming' | 'outgoing';
17
+ invoice: string;
18
+ paymentHash: string;
19
+ amountMsat: number;
20
+ feesPaidMsat?: number;
21
+ preimage?: string;
22
+ description?: string;
23
+ settledAt?: number;
24
+ createdAt: number;
25
+ expiresAt?: number;
26
+ }
27
+ export interface ServiceWallet {
28
+ alias(): string;
29
+ balanceMsat(): Promise<number>;
30
+ makeInvoice(request: {
31
+ amountMsat: number;
32
+ description?: string;
33
+ }): Promise<ServiceInvoice>;
34
+ payInvoice(request: {
35
+ invoice: string;
36
+ amountMsat: number;
37
+ }): Promise<{
38
+ preimage: string;
39
+ feesPaidMsat: number;
40
+ }>;
41
+ lookupInvoice(query: {
42
+ paymentHash?: string;
43
+ invoice?: string;
44
+ }): Promise<ServiceInvoice | null>;
45
+ }
46
+ export interface ServiceTransport {
47
+ subscribe(relays: string[], filter: Filter, onEvent: (event: NostrEvent) => void): Promise<() => void>;
48
+ publish(relays: string[], event: NostrEvent): Promise<void>;
49
+ }
50
+ export interface Grant {
51
+ id: string;
52
+ name: string;
53
+ serviceSecretHex: string;
54
+ servicePubkey: string;
55
+ clientSecretHex: string;
56
+ clientPubkey: string;
57
+ relays: string[];
58
+ methods: string[];
59
+ budgetMsat?: number;
60
+ spentMsat: number;
61
+ maxPaymentMsat?: number;
62
+ seen?: string[];
63
+ createdAt: number;
64
+ lastUsedAt?: number;
65
+ revokedAt?: number;
66
+ }
67
+ export declare function grantUri(grant: Grant): string;
68
+ export declare function spends(methods: string[]): boolean;
69
+ export declare function validateGrant(input: {
70
+ methods: string[];
71
+ relays: string[];
72
+ budgetMsat?: number;
73
+ maxPaymentMsat?: number;
74
+ }): void;
75
+ export declare function remainingBudgetMsat(grant: Grant): number;
76
+ export declare function newGrant(input: {
77
+ name: string;
78
+ relays: string[];
79
+ methods?: string[];
80
+ budgetMsat?: number;
81
+ maxPaymentMsat?: number;
82
+ }): Grant;
83
+ export declare function infoEvent(grant: Grant): NostrEvent;
84
+ export interface ServiceOptions {
85
+ wallet: ServiceWallet;
86
+ transport: ServiceTransport;
87
+ persist: () => Promise<void>;
88
+ log?: (message: string) => void;
89
+ now?: () => number;
90
+ }
91
+ export declare class WalletService {
92
+ #private;
93
+ constructor(opts: ServiceOptions);
94
+ serving(): string[];
95
+ serve(grant: Grant): Promise<void>;
96
+ stop(grantId: string): void;
97
+ close(): void;
98
+ }
@@ -0,0 +1,365 @@
1
+ import { finalizeEvent, getPublicKey, verifyEvent, nip44 } from 'nostr-tools';
2
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
3
+ import { tryDecodeBolt11 } from 'farrier-kit';
4
+ // A NIP-47 wallet service: the server side of Nostr Wallet Connect.
5
+ //
6
+ // bray already speaks NWC as a client - `zap-send` spends through a wallet
7
+ // the operator configured. This is the other direction. bray answers NIP-47
8
+ // on behalf of that wallet, and what it adds is the thing the wallet itself
9
+ // does not have: a connection you can hand to an agent that is narrower
10
+ // than the one you hold.
11
+ //
12
+ // That is the whole point. An NWC URI is an unbounded capability over a
13
+ // node - hand it to an agent and it can spend everything, forever, by any
14
+ // method the wallet supports. What is handed out here is scoped:
15
+ //
16
+ // - the method list is an allowlist, defaulting to no spending and no
17
+ // balance disclosure. Both are opt-in, per connection.
18
+ // - a connection that may spend MUST carry a budget. There is no
19
+ // unlimited grant to issue by accident.
20
+ // - a request that arrives twice is answered once. A relay will hand the
21
+ // same signed request over again, and a replayed pay_invoice is a
22
+ // second payment.
23
+ // - requests on one connection are serialised, so two arriving together
24
+ // cannot both read the same remaining budget.
25
+ //
26
+ // The same runtime, and the same rules, front a bearer-note wallet in
27
+ // forgesworn/notecase (src/nwcservice.ts). It is deliberately written
28
+ // against a `ServiceWallet` interface rather than against either wallet.
29
+ export const NWC_INFO_KIND = 13194;
30
+ export const NWC_REQUEST_KIND = 23194;
31
+ export const NWC_RESPONSE_KIND = 23195;
32
+ export const DEFAULT_METHODS = ['get_info', 'make_invoice', 'lookup_invoice'];
33
+ export const SUPPORTED_METHODS = [
34
+ 'get_info',
35
+ 'get_balance',
36
+ 'make_invoice',
37
+ 'lookup_invoice',
38
+ 'pay_invoice',
39
+ ];
40
+ export const SPENDING_METHODS = ['pay_invoice'];
41
+ // A request older than this is not answered. NIP-47 requests carry their
42
+ // own `expiration`, but that is the sender's word for it: a relay replaying
43
+ // a week-old event must not be able to spend, whatever the event claims
44
+ // about itself.
45
+ const MAX_REQUEST_AGE_SECS = 300;
46
+ const MAX_REQUEST_FUTURE_SECS = 60;
47
+ const SEEN_LIMIT = 256;
48
+ export class ServiceError extends Error {
49
+ code;
50
+ constructor(code, message) {
51
+ super(message);
52
+ this.code = code;
53
+ }
54
+ }
55
+ // Thrown by a wallet that refused BEFORE anything could leave: a bad
56
+ // invoice, an expired one, not enough balance. The only failure that gives
57
+ // the budget back, because it is the only one where nothing went out.
58
+ export class PaymentNotSentError extends ServiceError {
59
+ constructor(message) {
60
+ super('PAYMENT_FAILED', message);
61
+ }
62
+ }
63
+ export function grantUri(grant) {
64
+ const relays = grant.relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
65
+ return `nostr+walletconnect://${grant.servicePubkey}?${relays}&secret=${grant.clientSecretHex}`;
66
+ }
67
+ export function spends(methods) {
68
+ return methods.some((method) => SPENDING_METHODS.includes(method));
69
+ }
70
+ export function validateGrant(input) {
71
+ if (input.relays.length === 0)
72
+ throw new ServiceError('OTHER', 'A connection needs at least one relay.');
73
+ if (input.methods.length === 0)
74
+ throw new ServiceError('OTHER', 'A connection needs at least one method.');
75
+ for (const method of input.methods) {
76
+ if (!SUPPORTED_METHODS.includes(method)) {
77
+ throw new ServiceError('NOT_IMPLEMENTED', `This service cannot answer ${method}.`);
78
+ }
79
+ }
80
+ if (spends(input.methods)) {
81
+ if (input.budgetMsat === undefined) {
82
+ throw new ServiceError('OTHER', 'A connection that can spend needs a budget - there is no unlimited grant.');
83
+ }
84
+ if (!Number.isSafeInteger(input.budgetMsat) || input.budgetMsat <= 0) {
85
+ throw new ServiceError('OTHER', 'The budget must be a positive integer of milli-satoshis.');
86
+ }
87
+ }
88
+ else if (input.budgetMsat !== undefined) {
89
+ throw new ServiceError('OTHER', 'A budget means nothing on a connection that cannot spend.');
90
+ }
91
+ if (input.maxPaymentMsat !== undefined) {
92
+ if (!Number.isSafeInteger(input.maxPaymentMsat) || input.maxPaymentMsat <= 0) {
93
+ throw new ServiceError('OTHER', 'The per-payment ceiling must be a positive integer of milli-satoshis.');
94
+ }
95
+ if (input.budgetMsat !== undefined && input.maxPaymentMsat > input.budgetMsat) {
96
+ throw new ServiceError('OTHER', 'The per-payment ceiling is above the whole budget.');
97
+ }
98
+ }
99
+ }
100
+ export function remainingBudgetMsat(grant) {
101
+ return grant.budgetMsat === undefined ? 0 : Math.max(0, grant.budgetMsat - grant.spentMsat);
102
+ }
103
+ export function newGrant(input) {
104
+ const methods = input.methods ?? [...DEFAULT_METHODS];
105
+ validateGrant({ methods, relays: input.relays, ...(input.budgetMsat === undefined ? {} : { budgetMsat: input.budgetMsat }), ...(input.maxPaymentMsat === undefined ? {} : { maxPaymentMsat: input.maxPaymentMsat }) });
106
+ const serviceSecretHex = bytesToHex(randomSecret());
107
+ const clientSecretHex = bytesToHex(randomSecret());
108
+ return {
109
+ id: bytesToHex(randomSecret()).slice(0, 16),
110
+ name: input.name,
111
+ serviceSecretHex,
112
+ servicePubkey: getPublicKey(hexToBytes(serviceSecretHex)),
113
+ clientSecretHex,
114
+ clientPubkey: getPublicKey(hexToBytes(clientSecretHex)),
115
+ relays: [...new Set(input.relays)],
116
+ methods,
117
+ spentMsat: 0,
118
+ ...(input.budgetMsat === undefined ? {} : { budgetMsat: input.budgetMsat }),
119
+ ...(input.maxPaymentMsat === undefined ? {} : { maxPaymentMsat: input.maxPaymentMsat }),
120
+ createdAt: Date.now(),
121
+ };
122
+ }
123
+ function randomSecret() {
124
+ const bytes = new Uint8Array(32);
125
+ crypto.getRandomValues(bytes);
126
+ return bytes;
127
+ }
128
+ function conversationKey(grant) {
129
+ return nip44.getConversationKey(hexToBytes(grant.serviceSecretHex), grant.clientPubkey);
130
+ }
131
+ export function infoEvent(grant) {
132
+ return finalizeEvent({
133
+ kind: NWC_INFO_KIND,
134
+ created_at: Math.floor(Date.now() / 1000),
135
+ tags: [['encryption', 'nip44_v2']],
136
+ content: grant.methods.join(' '),
137
+ }, hexToBytes(grant.serviceSecretHex));
138
+ }
139
+ function responseEvent(grant, request, answer) {
140
+ return finalizeEvent({
141
+ kind: NWC_RESPONSE_KIND,
142
+ created_at: Math.floor(Date.now() / 1000),
143
+ tags: [
144
+ ['p', request.pubkey],
145
+ ['e', request.id],
146
+ ['encryption', 'nip44_v2'],
147
+ ],
148
+ content: nip44.encrypt(JSON.stringify(answer), conversationKey(grant)),
149
+ }, hexToBytes(grant.serviceSecretHex));
150
+ }
151
+ function invoiceResult(view) {
152
+ return {
153
+ type: view.type,
154
+ invoice: view.invoice,
155
+ payment_hash: view.paymentHash,
156
+ amount: view.amountMsat,
157
+ created_at: view.createdAt,
158
+ ...(view.description === undefined ? {} : { description: view.description }),
159
+ ...(view.expiresAt === undefined ? {} : { expires_at: view.expiresAt }),
160
+ ...(view.settledAt === undefined ? {} : { settled_at: view.settledAt }),
161
+ ...(view.preimage === undefined ? {} : { preimage: view.preimage }),
162
+ ...(view.feesPaidMsat === undefined ? {} : { fees_paid: view.feesPaidMsat }),
163
+ };
164
+ }
165
+ export class WalletService {
166
+ #opts;
167
+ #now;
168
+ #stops = new Map();
169
+ #queues = new Map();
170
+ #grants = new Map();
171
+ constructor(opts) {
172
+ this.#opts = opts;
173
+ this.#now = opts.now ?? (() => Date.now());
174
+ }
175
+ serving() {
176
+ return [...this.#stops.keys()];
177
+ }
178
+ async serve(grant) {
179
+ if (grant.revokedAt)
180
+ return;
181
+ this.stop(grant.id);
182
+ this.#grants.set(grant.id, grant);
183
+ await this.#opts.transport.publish(grant.relays, infoEvent(grant));
184
+ const since = Math.floor(this.#now() / 1000) - MAX_REQUEST_AGE_SECS;
185
+ const stop = await this.#opts.transport.subscribe(grant.relays, { kinds: [NWC_REQUEST_KIND], '#p': [grant.servicePubkey], since }, (event) => this.#enqueue(grant.id, event));
186
+ this.#stops.set(grant.id, stop);
187
+ this.#opts.log?.(`serving ${grant.name} on ${grant.relays.join(', ')}`);
188
+ }
189
+ stop(grantId) {
190
+ this.#stops.get(grantId)?.();
191
+ this.#stops.delete(grantId);
192
+ this.#grants.delete(grantId);
193
+ }
194
+ close() {
195
+ for (const id of [...this.#stops.keys()])
196
+ this.stop(id);
197
+ }
198
+ // Every request on one connection runs after the last has finished. Two
199
+ // pay_invoice requests arriving together must not both read the same
200
+ // remaining budget and both decide there is room.
201
+ #enqueue(grantId, event) {
202
+ const previous = this.#queues.get(grantId) ?? Promise.resolve();
203
+ this.#queues.set(grantId, previous
204
+ .then(() => this.#handle(grantId, event))
205
+ .catch((err) => this.#opts.log?.(`request failed: ${err.message}`)));
206
+ }
207
+ async #handle(grantId, event) {
208
+ const grant = this.#grants.get(grantId);
209
+ if (!grant || grant.revokedAt)
210
+ return;
211
+ // Anyone can publish an event tagged at this pubkey - it is in the info
212
+ // event, which is public. What keeps a stranger out is further down:
213
+ // the conversation key is derived from THIS connection's client pubkey,
214
+ // so NIP-44 cannot open anything anyone else sealed. This is the cheap
215
+ // rejection of noise before the expensive one.
216
+ if (event.pubkey !== grant.clientPubkey)
217
+ return;
218
+ if (!verifyEvent(event))
219
+ return;
220
+ const seconds = Math.floor(this.#now() / 1000);
221
+ if (event.created_at < seconds - MAX_REQUEST_AGE_SECS)
222
+ return;
223
+ if (event.created_at > seconds + MAX_REQUEST_FUTURE_SECS)
224
+ return;
225
+ const expiration = event.tags.find((tag) => tag[0] === 'expiration')?.[1];
226
+ if (expiration && Number(expiration) < seconds)
227
+ return;
228
+ const seen = grant.seen ?? [];
229
+ if (seen.includes(event.id)) {
230
+ this.#opts.log?.(`ignored a replayed request on ${grant.name}`);
231
+ return;
232
+ }
233
+ let parsed;
234
+ try {
235
+ parsed = JSON.parse(nip44.decrypt(event.content, conversationKey(grant)));
236
+ }
237
+ catch {
238
+ this.#opts.log?.(`could not read a request on ${grant.name}`);
239
+ return;
240
+ }
241
+ const method = typeof parsed.method === 'string' ? parsed.method : '';
242
+ const params = (parsed.params ?? {});
243
+ const remember = async () => {
244
+ grant.seen = [...seen, event.id].slice(-SEEN_LIMIT);
245
+ grant.lastUsedAt = this.#now();
246
+ await this.#opts.persist();
247
+ };
248
+ // A spending request is written down before it runs, so a crash between
249
+ // the two leaves a request that will not be tried again rather than one
250
+ // that might be paid twice.
251
+ const spending = SPENDING_METHODS.includes(method);
252
+ if (spending)
253
+ await remember();
254
+ let answer;
255
+ try {
256
+ if (!grant.methods.includes(method)) {
257
+ throw new ServiceError('RESTRICTED', SUPPORTED_METHODS.includes(method)
258
+ ? `This connection may not ${method}.`
259
+ : `This service cannot answer ${method}.`);
260
+ }
261
+ answer = { result_type: method, result: await this.#dispatch(grant, method, params) };
262
+ }
263
+ catch (err) {
264
+ const code = err instanceof ServiceError ? err.code : 'INTERNAL';
265
+ const message = err.message || 'The wallet could not answer that.';
266
+ answer = { result_type: method, error: { code, message } };
267
+ this.#opts.log?.(`refused ${method || 'an unnamed method'} on ${grant.name}: ${message}`);
268
+ }
269
+ if (!spending)
270
+ await remember();
271
+ await this.#opts.transport.publish(grant.relays, responseEvent(grant, event, answer));
272
+ }
273
+ async #dispatch(grant, method, params) {
274
+ const wallet = this.#opts.wallet;
275
+ switch (method) {
276
+ case 'get_info':
277
+ return { alias: wallet.alias(), network: 'mainnet', methods: grant.methods, notifications: [] };
278
+ case 'get_balance':
279
+ return { balance: await wallet.balanceMsat() };
280
+ case 'make_invoice': {
281
+ const amountMsat = Number(params.amount);
282
+ if (!Number.isSafeInteger(amountMsat) || amountMsat <= 0) {
283
+ throw new ServiceError('OTHER', 'make_invoice needs an amount in milli-satoshis.');
284
+ }
285
+ const description = typeof params.description === 'string' ? params.description : undefined;
286
+ return invoiceResult(await wallet.makeInvoice({ amountMsat, ...(description === undefined ? {} : { description }) }));
287
+ }
288
+ 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) {
292
+ throw new ServiceError('OTHER', 'lookup_invoice needs a payment_hash or an invoice.');
293
+ }
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.');
300
+ return invoiceResult(view);
301
+ }
302
+ case 'pay_invoice': {
303
+ const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
304
+ if (!invoice)
305
+ 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();
313
+ try {
314
+ const paid = await this.#opts.wallet.payInvoice({ invoice, amountMsat });
315
+ return { preimage: paid.preimage, fees_paid: paid.feesPaidMsat };
316
+ }
317
+ catch (err) {
318
+ if (err instanceof PaymentNotSentError) {
319
+ grant.spentMsat -= amountMsat;
320
+ await this.#opts.persist();
321
+ }
322
+ throw err;
323
+ }
324
+ }
325
+ default:
326
+ throw new ServiceError('NOT_IMPLEMENTED', `This service cannot answer ${method}.`);
327
+ }
328
+ }
329
+ }
330
+ // What this payment costs the budget, read off the invoice itself rather
331
+ // than taken from the request. A budget checked against a figure the payer
332
+ // supplied is not a budget.
333
+ function priceOf(invoice, params) {
334
+ const decoded = tryDecodeBolt11(invoice);
335
+ if (!decoded)
336
+ throw new PaymentNotSentError('That is not a decodable BOLT-11 invoice.');
337
+ const asked = params.amount === undefined ? undefined : Number(params.amount);
338
+ if (asked !== undefined && (!Number.isSafeInteger(asked) || asked <= 0)) {
339
+ throw new PaymentNotSentError('That amount is not a whole number of milli-satoshis.');
340
+ }
341
+ if (decoded.amountMsats !== null) {
342
+ const stated = Number(decoded.amountMsats);
343
+ if (asked !== undefined && asked !== stated) {
344
+ throw new PaymentNotSentError(`That invoice is for ${stated} msat, not the ${asked} msat asked for.`);
345
+ }
346
+ return stated;
347
+ }
348
+ if (asked === undefined)
349
+ throw new PaymentNotSentError('That invoice states no amount - say how much to send.');
350
+ return asked;
351
+ }
352
+ // Refuses before anything is attempted, and says which ceiling it hit: a
353
+ // payer told only "no" tries again.
354
+ function charge(grant, amountMsat) {
355
+ if (grant.budgetMsat === undefined) {
356
+ throw new ServiceError('RESTRICTED', 'This connection has no budget to spend from.');
357
+ }
358
+ if (grant.maxPaymentMsat !== undefined && amountMsat > grant.maxPaymentMsat) {
359
+ throw new ServiceError('QUOTA_EXCEEDED', `That is ${amountMsat} msat and this connection's ceiling for one payment is ${grant.maxPaymentMsat} msat.`);
360
+ }
361
+ 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.`);
364
+ }
365
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ToolDeps } from '../identity/tools.js';
3
+ export declare function registerWalletServiceTools(server: McpServer, deps: ToolDeps): void;
@@ -0,0 +1,189 @@
1
+ import { z } from 'zod';
2
+ import { resolveNwcUri } from '../zap/handlers.js';
3
+ import { defaultGrantsFile, loadGrants, saveGrants } from './grants.js';
4
+ import { upstreamWallet } from './upstream.js';
5
+ import { DEFAULT_METHODS, SUPPORTED_METHODS, WalletService, grantUri, newGrant, remainingBudgetMsat, } from './service.js';
6
+ // Handing out a wallet connection that is narrower than the one you hold.
7
+ //
8
+ // `zap-send` spends through the operator's own NWC URI, which is an
9
+ // unbounded capability: every method the wallet supports, no ceiling, for
10
+ // as long as it exists. These tools issue connections over that same
11
+ // wallet that are not - a method allowlist, a budget that spending
12
+ // requires, a per-payment ceiling, and a revocation that takes effect the
13
+ // moment it is asked for.
14
+ //
15
+ // Nothing is answered unless the service is running, and it runs for as
16
+ // long as this process does. That is worth saying out loud rather than
17
+ // leaving someone to discover it: a grant handed to an agent that only
18
+ // works while an MCP session is open is a different promise from one that
19
+ // 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.
24
+ let service = null;
25
+ let servingFor = null;
26
+ const served = new Map();
27
+ const text = (value) => ({
28
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
29
+ });
30
+ const shown = (grant) => ({
31
+ id: grant.id,
32
+ name: grant.name,
33
+ methods: grant.methods,
34
+ state: grant.revokedAt ? 'revoked' : 'live',
35
+ ...(grant.budgetMsat === undefined
36
+ ? {}
37
+ : { budgetMsat: grant.budgetMsat, spentMsat: grant.spentMsat, remainingMsat: remainingBudgetMsat(grant) }),
38
+ ...(grant.maxPaymentMsat === undefined ? {} : { maxPaymentMsat: grant.maxPaymentMsat }),
39
+ ...(grant.lastUsedAt === undefined ? {} : { lastUsedAt: new Date(grant.lastUsedAt).toISOString() }),
40
+ });
41
+ export function registerWalletServiceTools(server, deps) {
42
+ const grantsFile = defaultGrantsFile();
43
+ const identity = () => deps.ctx.activePublicKeyHex;
44
+ const read = () => loadGrants(grantsFile, identity());
45
+ const write = (grants) => {
46
+ saveGrants(grantsFile, identity(), grants);
47
+ };
48
+ const running = async () => {
49
+ const pubkey = identity();
50
+ // An identity switch is a different wallet and different grants, so the
51
+ // old service is stopped rather than left answering under a name this
52
+ // session no longer means.
53
+ if (service && servingFor !== pubkey) {
54
+ service.close();
55
+ service = null;
56
+ served.clear();
57
+ }
58
+ if (service)
59
+ return service;
60
+ const uri = resolveNwcUri(deps.ctx, deps.walletsFile, deps.nwcUri);
61
+ if (!uri)
62
+ throw new Error('No wallet is configured - `zap-wallet-set` points this identity at one first.');
63
+ servingFor = pubkey;
64
+ service = new WalletService({
65
+ wallet: upstreamWallet({ uri, alias: deps.ctx.activeNpub }),
66
+ transport: {
67
+ subscribe: (relays, filter, onEvent) => deps.pool.subscribe(relays, filter, onEvent),
68
+ publish: async (relays, event) => {
69
+ await deps.pool.publishDirect(relays, event);
70
+ },
71
+ },
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
+ },
79
+ });
80
+ return service;
81
+ };
82
+ const startServing = async (grant) => {
83
+ served.set(grant.id, grant);
84
+ await (await running()).serve(grant);
85
+ };
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.',
120
+ });
121
+ });
122
+ server.registerTool('wallet-grants', {
123
+ 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
+ annotations: { readOnlyHint: true },
125
+ }, async () => text({
126
+ serving: service?.serving() ?? [],
127
+ grants: read().map(shown),
128
+ }));
129
+ server.registerTool('wallet-revoke', {
130
+ description: 'Revoke a connection by name or id. It stops being answered immediately; nothing needs to be published anywhere.',
131
+ inputSchema: { nameOrId: z.string() },
132
+ annotations: { readOnlyHint: false, destructiveHint: true },
133
+ }, 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);
143
+ service?.stop(grant.id);
144
+ served.delete(grant.id);
145
+ return text({ ok: true, ...shown(grant) });
146
+ });
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
+ });
189
+ }
@@ -0,0 +1,8 @@
1
+ import type { NwcTransport } from '@forgesworn/nwc-kit';
2
+ import { type ServiceWallet } from './service.js';
3
+ export interface UpstreamOptions {
4
+ uri: string;
5
+ transport?: NwcTransport;
6
+ alias?: string;
7
+ }
8
+ export declare function upstreamWallet(options: UpstreamOptions): ServiceWallet;
@@ -0,0 +1,97 @@
1
+ import { NwcClient } from '@forgesworn/nwc-kit';
2
+ import { tryDecodeBolt11, verifyPreimage } from 'farrier-kit';
3
+ import { PaymentNotSentError, ServiceError } from './service.js';
4
+ async function withClient(options, run) {
5
+ const client = new NwcClient(options.uri, { ...(options.transport ? { transport: options.transport } : {}) });
6
+ try {
7
+ return await run(client);
8
+ }
9
+ finally {
10
+ client.close();
11
+ }
12
+ }
13
+ export function upstreamWallet(options) {
14
+ return {
15
+ alias: () => options.alias ?? 'bray',
16
+ balanceMsat: async () => withClient(options, async (client) => {
17
+ await client.connect();
18
+ const balance = await client.getBalance();
19
+ return Number(balance.balance ?? 0);
20
+ }),
21
+ async makeInvoice({ amountMsat, description }) {
22
+ return withClient(options, async (client) => {
23
+ const capabilities = await client.connect();
24
+ if (!capabilities.methods.includes('make_invoice')) {
25
+ throw new ServiceError('NOT_IMPLEMENTED', 'The wallet behind this service cannot issue invoices.');
26
+ }
27
+ const transaction = await client.makeInvoice({
28
+ amount: amountMsat,
29
+ ...(description === undefined ? {} : { description }),
30
+ });
31
+ const invoice = String(transaction.invoice ?? '');
32
+ const decoded = tryDecodeBolt11(invoice);
33
+ if (!decoded) {
34
+ throw new ServiceError('INTERNAL', 'The wallet returned something that is not a decodable invoice.');
35
+ }
36
+ // An invoice for a different amount than the one asked for is not a
37
+ // smaller problem than no invoice: whoever pays it pays that.
38
+ if (decoded.amountMsats !== null && Number(decoded.amountMsats) !== amountMsat) {
39
+ throw new ServiceError('INTERNAL', `The wallet issued an invoice for ${String(decoded.amountMsats)} msat, not the ${amountMsat} msat asked for.`);
40
+ }
41
+ return {
42
+ type: 'incoming',
43
+ invoice,
44
+ paymentHash: decoded.paymentHashHex,
45
+ amountMsat,
46
+ createdAt: decoded.timestamp,
47
+ expiresAt: decoded.timestamp + decoded.expirySeconds,
48
+ ...(description === undefined ? {} : { description }),
49
+ };
50
+ });
51
+ },
52
+ async payInvoice({ invoice }) {
53
+ const decoded = tryDecodeBolt11(invoice);
54
+ if (!decoded)
55
+ throw new PaymentNotSentError('That is not a decodable BOLT-11 invoice.');
56
+ return withClient(options, async (client) => {
57
+ const capabilities = await client.connect();
58
+ if (!capabilities.methods.includes('pay_invoice')) {
59
+ throw new PaymentNotSentError('The wallet behind this service cannot pay invoices.');
60
+ }
61
+ const result = await client.payInvoice({ invoice });
62
+ if (!result.preimage || !verifyPreimage(result.preimage, decoded.paymentHashHex)) {
63
+ // The wallet says it paid and cannot prove it. Not a refusal -
64
+ // something may well have gone out - so the budget keeps the
65
+ // charge and the caller is told the proof is missing.
66
+ throw new ServiceError('OTHER', 'The wallet claims payment but its preimage does not settle that invoice.');
67
+ }
68
+ return { preimage: result.preimage, feesPaidMsat: Number(result.fees_paid ?? 0) };
69
+ });
70
+ },
71
+ async lookupInvoice(query) {
72
+ return withClient(options, async (client) => {
73
+ const capabilities = await client.connect();
74
+ if (!capabilities.methods.includes('lookup_invoice')) {
75
+ throw new ServiceError('NOT_IMPLEMENTED', 'The wallet behind this service cannot look invoices up.');
76
+ }
77
+ const found = await client.lookupInvoice({
78
+ ...(query.paymentHash === undefined ? {} : { payment_hash: query.paymentHash }),
79
+ ...(query.invoice === undefined ? {} : { invoice: query.invoice }),
80
+ });
81
+ if (!found)
82
+ return null;
83
+ return {
84
+ type: found.type === 'outgoing' ? 'outgoing' : 'incoming',
85
+ invoice: String(found.invoice ?? ''),
86
+ paymentHash: String(found.payment_hash ?? ''),
87
+ amountMsat: Number(found.amount ?? 0),
88
+ createdAt: Number(found.created_at ?? 0),
89
+ ...(found.settled_at === undefined ? {} : { settledAt: Number(found.settled_at) }),
90
+ ...(found.preimage === undefined ? {} : { preimage: String(found.preimage) }),
91
+ ...(found.fees_paid === undefined ? {} : { feesPaidMsat: Number(found.fees_paid) }),
92
+ ...(found.description === undefined ? {} : { description: String(found.description) }),
93
+ };
94
+ });
95
+ },
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "mcpName": "dev.forgesworn/bray",
5
5
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
6
6
  "type": "module",