nostr-bray 3.2.2 → 3.4.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 +75 -1
- package/dist/cli/index.js +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +61 -7
- package/dist/index.js +8 -1
- package/dist/types.d.ts +5 -0
- package/dist/wallet-service/grants.d.ts +8 -0
- package/dist/wallet-service/grants.js +56 -0
- package/dist/wallet-service/service.d.ts +98 -0
- package/dist/wallet-service/service.js +365 -0
- package/dist/wallet-service/tools.d.ts +3 -0
- package/dist/wallet-service/tools.js +189 -0
- package/dist/wallet-service/upstream.d.ts +8 -0
- package/dist/wallet-service/upstream.js +97 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# nostr-bray
|
|
2
2
|
|
|
3
|
-
**Trust-aware Nostr MCP for AI and humans.**
|
|
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
|
[](https://www.npmjs.com/package/nostr-bray)
|
|
6
6
|
[](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` |
|
|
@@ -214,6 +215,7 @@ Search order: `BRAY_CONFIG` env var > `$XDG_CONFIG_HOME/bray/config.json` > `~/.
|
|
|
214
215
|
| `NOSTR_NCRYPTSEC_PASSWORD` | Password for ncryptsec |
|
|
215
216
|
| `NWC_URI_FILE` | Path to a private `0600` file containing the NWC bearer URI |
|
|
216
217
|
| `NOSTR_RELAYS` | Comma-separated relay URLs |
|
|
218
|
+
| `NOSTR_FORBID_PUBKEY` | Comma-separated npubs or hex this process must never sign as. See below. |
|
|
217
219
|
| `TOR_PROXY` | SOCKS5h proxy for Tor |
|
|
218
220
|
| `NIP04_ENABLED` | Set `1` to enable legacy NIP-04 DMs |
|
|
219
221
|
| `TRANSPORT` | `stdio` (default) or `http` |
|
|
@@ -224,6 +226,78 @@ Raw `NWC_URI` is refused; use `NWC_URI_FILE` or `wallet connect <nwc-file>` so
|
|
|
224
226
|
the bearer credential never appears in a process environment, command argument
|
|
225
227
|
or MCP tool argument.
|
|
226
228
|
|
|
229
|
+
### Keys this process must never be
|
|
230
|
+
|
|
231
|
+
An agent that comes up holding the wrong key is indistinguishable from one
|
|
232
|
+
working correctly. Every signature verifies, every tool succeeds, and the
|
|
233
|
+
only symptom is that `whoami` quietly answers with somebody else's npub —
|
|
234
|
+
which the driving model has no reason to doubt, because it is its own tool
|
|
235
|
+
telling it.
|
|
236
|
+
|
|
237
|
+
The worst version is a **principal's** key: a person's own. An agent signing
|
|
238
|
+
as its principal can attest that it belongs to itself, and approve its own
|
|
239
|
+
requests, because it *is* the principal those checks look for.
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
NOSTR_FORBID_PUBKEY=npub1yourownkey...,npub1anotherhuman...
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
or `forbidPubkeys` in the config file. Either takes npubs or hex. Activating
|
|
246
|
+
one of those keys throws instead — at startup, on `identity-switch`, and on
|
|
247
|
+
`bunker --persona` alike, because the check sits at the moment an identity
|
|
248
|
+
becomes active rather than at any one entry point. An entry that cannot be
|
|
249
|
+
parsed is an error too: a list nobody can read must not silently permit
|
|
250
|
+
everything.
|
|
251
|
+
|
|
252
|
+
The active npub is printed on stderr on every start, so the answer to "which
|
|
253
|
+
key is this?" does not depend on asking the agent.
|
|
254
|
+
|
|
255
|
+
## Handing out a wallet connection
|
|
256
|
+
|
|
257
|
+
`zap-send` spends through the NWC URI you configured. That URI is an
|
|
258
|
+
unbounded capability over the wallet behind it: every method it supports, no
|
|
259
|
+
ceiling, until it is rotated. Handing one to an agent hands over everything.
|
|
260
|
+
|
|
261
|
+
`wallet-grant` issues connections over the same wallet that are narrower.
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
wallet-grant { name: "research-agent", methods: ["get_info","pay_invoice"],
|
|
265
|
+
budgetMsat: 50000, maxPaymentMsat: 5000 }
|
|
266
|
+
wallet-grants # what exists, what it may do, what it has spent
|
|
267
|
+
wallet-revoke { nameOrId: "research-agent" }
|
|
268
|
+
wallet-refill { nameOrId: "research-agent", budgetMsat: 50000 }
|
|
269
|
+
wallet-serve { action: "start" }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
What a connection may do is an allowlist, and the default grants no spending
|
|
273
|
+
and does not disclose the balance: `get_info`, `make_invoice`,
|
|
274
|
+
`lookup_invoice`. Both of the others are opt-in per connection, and **a
|
|
275
|
+
connection that can spend must carry a budget** - there is no unlimited grant
|
|
276
|
+
to issue by accident.
|
|
277
|
+
|
|
278
|
+
The rules exist because a payment cannot be recalled:
|
|
279
|
+
|
|
280
|
+
- A request is answered **once**. Its id is persisted *before* the payment is
|
|
281
|
+
attempted, so a process that dies mid-payment comes back knowing not to try
|
|
282
|
+
again - a relay will hand the same signed request over twice.
|
|
283
|
+
- Requests on one connection are **serialised**, so two arriving together
|
|
284
|
+
cannot both read the same remaining budget.
|
|
285
|
+
- A request older than five minutes is not answered, whatever its own
|
|
286
|
+
`expiration` tag claims.
|
|
287
|
+
- Each connection has **its own service key**: two grants share nothing a
|
|
288
|
+
relay can correlate, and revoking one is deleting a key.
|
|
289
|
+
- The budget is charged what the **invoice** says, decoded here. A budget
|
|
290
|
+
checked against a figure the payer supplied is not a budget.
|
|
291
|
+
- A claimed payment whose preimage does not settle the invoice is refused
|
|
292
|
+
rather than passed on. Repeating an unproven claim would make this service
|
|
293
|
+
the one telling the lie.
|
|
294
|
+
|
|
295
|
+
Grants live in `wallet-grants.json` (0600, per identity), and they are
|
|
296
|
+
answered **only while `wallet-serve` is running** - which is to say, only for
|
|
297
|
+
as long as this process is alive. That is worth telling whoever you hand a URI
|
|
298
|
+
to: a connection that works while an MCP session is open is a different
|
|
299
|
+
promise from one that works overnight.
|
|
300
|
+
|
|
227
301
|
## CLI
|
|
228
302
|
|
|
229
303
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -384,7 +384,7 @@ if (config.bunkerUri) {
|
|
|
384
384
|
console.error(`Connected to ${hw ? 'Heartwood' : 'bunker'} — signing as ${ctx.activeNpub}`);
|
|
385
385
|
}
|
|
386
386
|
else {
|
|
387
|
-
ctx = new IdentityContext(config.secretKey, config.secretFormat);
|
|
387
|
+
ctx = new IdentityContext(config.secretKey, config.secretFormat, { forbidPubkeys: config.forbidPubkeys });
|
|
388
388
|
}
|
|
389
389
|
// NIP-42: the pool answers AUTH challenges with the active identity's key.
|
|
390
390
|
pool.setAuthSigner(async (evt) => ctx.getSigningFunction()(evt));
|
package/dist/config.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ interface ConfigFile {
|
|
|
36
36
|
trustCacheTtl?: number;
|
|
37
37
|
trustCacheMax?: number;
|
|
38
38
|
dispatchIdentities?: string;
|
|
39
|
+
/** Public keys this process must never sign as. See ContextOptions. */
|
|
40
|
+
forbidPubkeys?: string[];
|
|
39
41
|
}
|
|
40
42
|
/**
|
|
41
43
|
* Load config from a JSON file. Searches in order:
|
package/dist/config.js
CHANGED
|
@@ -283,6 +283,14 @@ export async function loadConfig() {
|
|
|
283
283
|
: 'weekly';
|
|
284
284
|
// --- Dispatch identities file ---
|
|
285
285
|
const dispatchIdentities = process.env.DISPATCH_IDENTITIES ?? file.dispatchIdentities ?? undefined;
|
|
286
|
+
// Comma-separated npubs or hex. A list nobody can parse is rejected at
|
|
287
|
+
// startup by resolvePubkeyRef rather than silently permitting everything.
|
|
288
|
+
const forbidPubkeys = [
|
|
289
|
+
...(process.env.NOSTR_FORBID_PUBKEY ?? '').split(','),
|
|
290
|
+
...(file.forbidPubkeys ?? []),
|
|
291
|
+
]
|
|
292
|
+
.map((s) => s.trim())
|
|
293
|
+
.filter(Boolean);
|
|
286
294
|
// --- Wallets file ---
|
|
287
295
|
const walletsFile = process.env.BRAY_WALLETS_FILE
|
|
288
296
|
?? file.walletsFile
|
|
@@ -309,5 +317,6 @@ export async function loadConfig() {
|
|
|
309
317
|
port,
|
|
310
318
|
bindAddress,
|
|
311
319
|
dispatchIdentities,
|
|
320
|
+
forbidPubkeys,
|
|
312
321
|
};
|
|
313
322
|
}
|
package/dist/context.d.ts
CHANGED
|
@@ -3,14 +3,41 @@ import type { PublicIdentity, SignFn } from './types.js';
|
|
|
3
3
|
import type { ExtendedSigningContext } from './signing-context.js';
|
|
4
4
|
export interface ContextOptions {
|
|
5
5
|
maxCache?: number;
|
|
6
|
+
/** Public keys this process must never sign as, hex or npub.
|
|
7
|
+
*
|
|
8
|
+
* For PRINCIPALS - a person's own key. An agent that comes up holding one
|
|
9
|
+
* is indistinguishable from one working correctly: every signature
|
|
10
|
+
* verifies, every tool succeeds, and the only symptom is that `whoami`
|
|
11
|
+
* quietly answers with a human's npub. Listing them here turns that into
|
|
12
|
+
* a refusal at the moment of activation, which covers startup, an
|
|
13
|
+
* `identity-switch`, and `bunker --persona` alike. */
|
|
14
|
+
forbidPubkeys?: string[];
|
|
6
15
|
}
|
|
16
|
+
/** An npub or 64-hex pubkey as lowercase hex. Throws on anything else, so a
|
|
17
|
+
* forbidden entry nobody can parse fails loudly rather than silently
|
|
18
|
+
* permitting everything. */
|
|
19
|
+
export declare function resolvePubkeyRef(ref: string): string;
|
|
7
20
|
export declare class IdentityContext implements ExtendedSigningContext {
|
|
8
21
|
private root;
|
|
9
22
|
private cache;
|
|
10
23
|
private masterEntry;
|
|
11
24
|
private activeEntry;
|
|
12
25
|
private maxCacheSize;
|
|
26
|
+
private forbidden;
|
|
13
27
|
constructor(secretKey: string, format: 'nsec' | 'hex' | 'mnemonic', opts?: ContextOptions);
|
|
28
|
+
/**
|
|
29
|
+
* Make an identity the active one, refusing a forbidden key.
|
|
30
|
+
*
|
|
31
|
+
* Every assignment to `activeEntry` goes through here - the constructor,
|
|
32
|
+
* `use()`, `switch()` and the persona paths - so there is no route to
|
|
33
|
+
* signing as a forbidden key, and no new route can be added by accident.
|
|
34
|
+
*
|
|
35
|
+
* Throws rather than falling back to the master identity. A process asked
|
|
36
|
+
* to be a key it must not be has been misconfigured, and quietly running
|
|
37
|
+
* as something else is how the misconfiguration survives to do damage.
|
|
38
|
+
* Only the npub is in the message; it is public, and nothing else is.
|
|
39
|
+
*/
|
|
40
|
+
private activate;
|
|
14
41
|
/** Current active identity's npub (bech32) */
|
|
15
42
|
get activeNpub(): string;
|
|
16
43
|
/** Derivation path of the current active identity, for reporting to a human */
|
package/dist/context.js
CHANGED
|
@@ -2,6 +2,7 @@ import { fromNsec, fromMnemonic, derive, zeroise } from 'nsec-tree';
|
|
|
2
2
|
import { createBlindProof, createFullProof } from 'nsec-tree/proof';
|
|
3
3
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
|
4
4
|
import { decode, npubEncode, nsecEncode } from 'nostr-tools/nip19';
|
|
5
|
+
import { decode as decodeNip19 } from 'nostr-tools/nip19';
|
|
5
6
|
/** Build an Identity-compatible object from raw key bytes */
|
|
6
7
|
function rawIdentity(privateKey) {
|
|
7
8
|
const publicKeyHex = getPublicKey(privateKey);
|
|
@@ -15,14 +16,45 @@ function rawIdentity(privateKey) {
|
|
|
15
16
|
index: 0,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
19
|
+
/** An npub or 64-hex pubkey as lowercase hex. Throws on anything else, so a
|
|
20
|
+
* forbidden entry nobody can parse fails loudly rather than silently
|
|
21
|
+
* permitting everything. */
|
|
22
|
+
export function resolvePubkeyRef(ref) {
|
|
23
|
+
const trimmed = ref.trim();
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
throw new Error('forbidPubkeys entry is empty');
|
|
26
|
+
if (/^[0-9a-fA-F]{64}$/.test(trimmed))
|
|
27
|
+
return trimmed.toLowerCase();
|
|
28
|
+
if (trimmed.toLowerCase().startsWith('nsec1')) {
|
|
29
|
+
throw new Error('forbidPubkeys wants a PUBLIC key; that is an nsec. Treat it as exposed.');
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const decoded = decodeNip19(trimmed);
|
|
33
|
+
if (decoded.type === 'npub')
|
|
34
|
+
return decoded.data;
|
|
35
|
+
throw new Error(`forbidPubkeys entry "${trimmed}" is a ${decoded.type}, not an npub`);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err instanceof Error && err.message.startsWith('forbidPubkeys'))
|
|
39
|
+
throw err;
|
|
40
|
+
throw new Error(`forbidPubkeys entry "${trimmed}" is neither an npub nor 64 hex characters`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
18
43
|
export class IdentityContext {
|
|
19
44
|
root;
|
|
20
45
|
cache = new Map();
|
|
21
46
|
masterEntry;
|
|
47
|
+
// Definite-assignment asserted: every branch of the constructor assigns it
|
|
48
|
+
// through activate(), and TypeScript does not track assignments made
|
|
49
|
+
// inside a called method.
|
|
22
50
|
activeEntry;
|
|
23
51
|
maxCacheSize;
|
|
52
|
+
forbidden;
|
|
24
53
|
constructor(secretKey, format, opts) {
|
|
25
54
|
this.maxCacheSize = opts?.maxCache ?? 5;
|
|
55
|
+
// Built before anything is activated, so the constructor's own
|
|
56
|
+
// activation is checked too.
|
|
57
|
+
this.forbidden = new Set((opts?.forbidPubkeys ?? []).map(resolvePubkeyRef));
|
|
26
58
|
// Parse the raw secret key bytes — this IS the user's actual Nostr identity
|
|
27
59
|
let rawKeyBytes;
|
|
28
60
|
if (format === 'mnemonic') {
|
|
@@ -30,7 +62,7 @@ export class IdentityContext {
|
|
|
30
62
|
this.root = fromMnemonic(secretKey);
|
|
31
63
|
const derived = derive(this.root, 'master', 0);
|
|
32
64
|
this.masterEntry = { identity: derived, purpose: 'master', index: 0, lastUsed: Date.now() };
|
|
33
|
-
this.
|
|
65
|
+
this.activate(this.masterEntry);
|
|
34
66
|
return;
|
|
35
67
|
}
|
|
36
68
|
else if (format === 'hex') {
|
|
@@ -47,10 +79,32 @@ export class IdentityContext {
|
|
|
47
79
|
index: 0,
|
|
48
80
|
lastUsed: Date.now(),
|
|
49
81
|
};
|
|
50
|
-
this.
|
|
82
|
+
this.activate(this.masterEntry);
|
|
51
83
|
// nsec-tree root for child derivation
|
|
52
84
|
this.root = fromNsec(rawKeyBytes);
|
|
53
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Make an identity the active one, refusing a forbidden key.
|
|
88
|
+
*
|
|
89
|
+
* Every assignment to `activeEntry` goes through here - the constructor,
|
|
90
|
+
* `use()`, `switch()` and the persona paths - so there is no route to
|
|
91
|
+
* signing as a forbidden key, and no new route can be added by accident.
|
|
92
|
+
*
|
|
93
|
+
* Throws rather than falling back to the master identity. A process asked
|
|
94
|
+
* to be a key it must not be has been misconfigured, and quietly running
|
|
95
|
+
* as something else is how the misconfiguration survives to do damage.
|
|
96
|
+
* Only the npub is in the message; it is public, and nothing else is.
|
|
97
|
+
*/
|
|
98
|
+
activate(entry) {
|
|
99
|
+
const hex = Buffer.from(entry.identity.publicKey).toString('hex');
|
|
100
|
+
if (this.forbidden.has(hex)) {
|
|
101
|
+
throw new Error(`Refusing to activate ${entry.identity.npub}: it is on this process's forbidden list. ` +
|
|
102
|
+
'That list is for principals - a person\'s key, never an agent\'s. An agent signing as its ' +
|
|
103
|
+
'principal can attest that it belongs to itself and approve its own requests, because it is ' +
|
|
104
|
+
'the principal those checks look for. Point NOSTR_SECRET_KEY at the agent\'s own key.');
|
|
105
|
+
}
|
|
106
|
+
this.activeEntry = entry;
|
|
107
|
+
}
|
|
54
108
|
/** Current active identity's npub (bech32) */
|
|
55
109
|
get activeNpub() {
|
|
56
110
|
return this.activeEntry.identity.npub;
|
|
@@ -90,14 +144,14 @@ export class IdentityContext {
|
|
|
90
144
|
for (const [, entry] of this.cache) {
|
|
91
145
|
if (entry.purpose === purpose && entry.index === index && !entry.personaName) {
|
|
92
146
|
entry.lastUsed = Date.now();
|
|
93
|
-
this.
|
|
147
|
+
this.activate(entry);
|
|
94
148
|
return;
|
|
95
149
|
}
|
|
96
150
|
}
|
|
97
151
|
const identity = derive(this.root, purpose, index);
|
|
98
152
|
const entry = { identity, purpose, index, lastUsed: Date.now() };
|
|
99
153
|
this.putCache(identity.npub, entry);
|
|
100
|
-
this.
|
|
154
|
+
this.activate(entry);
|
|
101
155
|
}
|
|
102
156
|
/** Derive a named persona */
|
|
103
157
|
async derivePersona(name, index) {
|
|
@@ -123,7 +177,7 @@ export class IdentityContext {
|
|
|
123
177
|
/** Switch active identity by purpose+index, persona name, or "master" */
|
|
124
178
|
async switch(purposeOrName, index) {
|
|
125
179
|
if (purposeOrName === 'master') {
|
|
126
|
-
this.
|
|
180
|
+
this.activate(this.masterEntry);
|
|
127
181
|
this.masterEntry.lastUsed = Date.now();
|
|
128
182
|
return;
|
|
129
183
|
}
|
|
@@ -133,7 +187,7 @@ export class IdentityContext {
|
|
|
133
187
|
const matchesPersona = entry.personaName === purposeOrName && (index === undefined || entry.index === index);
|
|
134
188
|
if (matchesPurpose || matchesPersona) {
|
|
135
189
|
entry.lastUsed = Date.now();
|
|
136
|
-
this.
|
|
190
|
+
this.activate(entry);
|
|
137
191
|
return;
|
|
138
192
|
}
|
|
139
193
|
}
|
|
@@ -160,7 +214,7 @@ export class IdentityContext {
|
|
|
160
214
|
lastUsed: Date.now(),
|
|
161
215
|
};
|
|
162
216
|
this.putCache(identity.npub, entry);
|
|
163
|
-
this.
|
|
217
|
+
this.activate(entry);
|
|
164
218
|
}
|
|
165
219
|
/** Get a signing function bound to the current active identity */
|
|
166
220
|
getSigningFunction() {
|
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';
|
|
@@ -103,7 +104,12 @@ if (config.bunkerUri) {
|
|
|
103
104
|
void establishBunkerInBackground(base);
|
|
104
105
|
}
|
|
105
106
|
else {
|
|
106
|
-
ctx = new IdentityContext(config.secretKey, config.secretFormat);
|
|
107
|
+
ctx = new IdentityContext(config.secretKey, config.secretFormat, { forbidPubkeys: config.forbidPubkeys });
|
|
108
|
+
// Said out loud, on stderr, every start. The alternative is asking the
|
|
109
|
+
// agent which identity it holds, and an agent whose key is wrong will
|
|
110
|
+
// answer that confidently and wrongly - it has no reason to doubt its
|
|
111
|
+
// own tools. stdout is reserved for JSON-RPC.
|
|
112
|
+
console.error(`Signing as ${ctx.activeNpub}`);
|
|
107
113
|
// Local key is ready immediately -- load its relay list now.
|
|
108
114
|
loadIdentityRelays(ctx.activeNpub);
|
|
109
115
|
}
|
|
@@ -157,6 +163,7 @@ registerTrustTools(proxy, deps);
|
|
|
157
163
|
registerRelayTools(proxy, deps);
|
|
158
164
|
registerRelayIntelligenceTools(proxy, deps);
|
|
159
165
|
registerZapTools(proxy, deps);
|
|
166
|
+
registerWalletServiceTools(proxy, deps);
|
|
160
167
|
registerSafetyTools(proxy, deps);
|
|
161
168
|
registerUtilTools(proxy, deps);
|
|
162
169
|
registerWorkflowTools(proxy, {
|
package/dist/types.d.ts
CHANGED
|
@@ -61,6 +61,11 @@ export interface BrayConfig {
|
|
|
61
61
|
readonly port: number;
|
|
62
62
|
readonly bindAddress: string;
|
|
63
63
|
readonly dispatchIdentities?: string;
|
|
64
|
+
/** Public keys this process must never sign as, hex or npub. For
|
|
65
|
+
* principals: an agent holding one can attest that it belongs to itself
|
|
66
|
+
* and approve its own requests. Enforced in IdentityContext, at the
|
|
67
|
+
* moment an identity is activated. */
|
|
68
|
+
readonly forbidPubkeys?: string[];
|
|
64
69
|
}
|
|
65
70
|
export type { SigningContext, ExtendedSigningContext } from './signing-context.js';
|
|
66
71
|
export { hasExtendedIdentity } from './signing-context.js';
|
|
@@ -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,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.
|
|
3
|
+
"version": "3.4.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",
|