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.
- package/README.md +77 -10
- package/dist/bunker-context.d.ts +18 -0
- package/dist/bunker-context.js +20 -5
- package/dist/catalog.d.ts +11 -1
- package/dist/catalog.js +28 -1
- package/dist/cli/commands/wallet.js +2 -1
- package/dist/cli/commands/zap.js +2 -2
- package/dist/cli/index.js +2 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/dispatch/tools.js +1 -1
- package/dist/file-lock.d.ts +2 -0
- package/dist/file-lock.js +71 -0
- package/dist/heartwood-context.d.ts +53 -7
- package/dist/heartwood-context.js +120 -22
- package/dist/http-token.d.ts +8 -0
- package/dist/http-token.js +18 -0
- package/dist/identity/tools.d.ts +3 -0
- package/dist/index.js +17 -64
- package/dist/marketplace/handlers.d.ts +8 -3
- package/dist/marketplace/handlers.js +21 -4
- package/dist/marketplace/tools.js +38 -6
- package/dist/sdk.js +4 -1
- package/dist/tool-groups.d.ts +16 -0
- package/dist/tool-groups.js +52 -0
- package/dist/types.d.ts +2 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +6 -0
- package/dist/wallet-service/grants.d.ts +24 -1
- package/dist/wallet-service/grants.js +172 -20
- package/dist/wallet-service/service.d.ts +9 -3
- package/dist/wallet-service/service.js +136 -36
- package/dist/wallet-service/tools.d.ts +10 -1
- package/dist/wallet-service/tools.js +101 -111
- package/dist/wallet-service/upstream.d.ts +2 -0
- package/dist/wallet-service/upstream.js +41 -5
- package/dist/zap/confirm.d.ts +16 -0
- package/dist/zap/confirm.js +58 -0
- package/dist/zap/handlers.d.ts +10 -1
- package/dist/zap/handlers.js +56 -6
- package/dist/zap/index.d.ts +1 -0
- package/dist/zap/index.js +1 -0
- package/dist/zap/payment-guard.d.ts +59 -0
- package/dist/zap/payment-guard.js +213 -0
- package/dist/zap/tools.js +29 -2
- package/llms-full.txt +13 -9
- package/llms.txt +4 -4
- package/package.json +18 -4
|
@@ -1,8 +1,31 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
file = parsed;
|
|
90
|
+
process.kill(pid, 0);
|
|
91
|
+
return pid;
|
|
43
92
|
}
|
|
44
|
-
catch {
|
|
45
|
-
|
|
46
|
-
// alternative is refusing to ever write again
|
|
93
|
+
catch (err) {
|
|
94
|
+
return err.code === 'EPERM' ? pid : null;
|
|
47
95
|
}
|
|
48
96
|
}
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
const invoice = typeof params.invoice === 'string' ? params.invoice : undefined;
|
|
291
|
-
if (!
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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;
|