2020117-agent 0.3.7 → 0.4.1
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/dist/agent.js +328 -6
- package/dist/nostr.d.ts +81 -0
- package/dist/nostr.js +295 -0
- package/dist/nwc.d.ts +27 -0
- package/dist/nwc.js +132 -0
- package/package.json +8 -3
package/dist/agent.js
CHANGED
|
@@ -19,8 +19,12 @@ for (const arg of process.argv.slice(2)) {
|
|
|
19
19
|
if (!arg.startsWith('--'))
|
|
20
20
|
continue;
|
|
21
21
|
const eq = arg.indexOf('=');
|
|
22
|
-
if (eq === -1)
|
|
22
|
+
if (eq === -1) {
|
|
23
|
+
// Bare flags (no value)
|
|
24
|
+
if (arg === '--sovereign')
|
|
25
|
+
process.env.SOVEREIGN = '1';
|
|
23
26
|
continue;
|
|
27
|
+
}
|
|
24
28
|
const key = arg.slice(0, eq);
|
|
25
29
|
const val = arg.slice(eq + 1);
|
|
26
30
|
switch (key) {
|
|
@@ -63,6 +67,18 @@ for (const arg of process.argv.slice(2)) {
|
|
|
63
67
|
case '--lightning-address':
|
|
64
68
|
process.env.LIGHTNING_ADDRESS = val;
|
|
65
69
|
break;
|
|
70
|
+
case '--sovereign':
|
|
71
|
+
process.env.SOVEREIGN = val || '1';
|
|
72
|
+
break;
|
|
73
|
+
case '--privkey':
|
|
74
|
+
process.env.NOSTR_PRIVKEY = val;
|
|
75
|
+
break;
|
|
76
|
+
case '--nwc':
|
|
77
|
+
process.env.NWC_URI = val;
|
|
78
|
+
break;
|
|
79
|
+
case '--relays':
|
|
80
|
+
process.env.NOSTR_RELAYS = val;
|
|
81
|
+
break;
|
|
66
82
|
}
|
|
67
83
|
}
|
|
68
84
|
import { randomBytes } from 'crypto';
|
|
@@ -71,6 +87,8 @@ import { createProcessor } from './processor.js';
|
|
|
71
87
|
import { hasApiKey, loadAgentName, registerService, startHeartbeatLoop, getInbox, acceptJob, sendFeedback, submitResult, createJob, getJob, getProfile, reportSession, } from './api.js';
|
|
72
88
|
import { generateInvoice } from './clink.js';
|
|
73
89
|
import { receiveCashuToken } from './cashu.js';
|
|
90
|
+
import { generateKeypair, loadSovereignKeys, saveSovereignKeys, signEvent, nip44Encrypt, nip44Decrypt, pubkeyFromPrivkey, RelayPool, } from './nostr.js';
|
|
91
|
+
import { parseNwcUri, nwcGetBalance } from './nwc.js';
|
|
74
92
|
import { readFileSync } from 'fs';
|
|
75
93
|
import WebSocket from 'ws';
|
|
76
94
|
// Polyfill global WebSocket for Node.js < 22 (needed by ws tunnel)
|
|
@@ -84,6 +102,10 @@ const SATS_PER_CHUNK = Number(process.env.SATS_PER_CHUNK) || 1;
|
|
|
84
102
|
const CHUNKS_PER_PAYMENT = Number(process.env.CHUNKS_PER_PAYMENT) || 10;
|
|
85
103
|
// --- Lightning payment config ---
|
|
86
104
|
let LIGHTNING_ADDRESS = process.env.LIGHTNING_ADDRESS || '';
|
|
105
|
+
// --- Sovereign mode config ---
|
|
106
|
+
const SOVEREIGN = process.env.SOVEREIGN === '1' || process.env.SOVEREIGN === 'true';
|
|
107
|
+
const DEFAULT_RELAYS = ['wss://relay.2020117.xyz', 'wss://relay.damus.io', 'wss://nos.lol'];
|
|
108
|
+
const RELAYS = process.env.NOSTR_RELAYS?.split(',').map(s => s.trim()) || DEFAULT_RELAYS;
|
|
87
109
|
// --- Sub-task delegation config ---
|
|
88
110
|
const SUB_KIND = process.env.SUB_KIND ? Number(process.env.SUB_KIND) : null;
|
|
89
111
|
const SUB_PROVIDER = process.env.SUB_PROVIDER || undefined;
|
|
@@ -119,6 +141,9 @@ const state = {
|
|
|
119
141
|
skill: loadSkill(),
|
|
120
142
|
p2pSessionsCompleted: 0,
|
|
121
143
|
p2pTotalEarnedSats: 0,
|
|
144
|
+
sovereignKeys: null,
|
|
145
|
+
relayPool: null,
|
|
146
|
+
nwcParsed: null,
|
|
122
147
|
};
|
|
123
148
|
// --- Capacity management ---
|
|
124
149
|
function acquireSlot() {
|
|
@@ -162,15 +187,22 @@ async function main() {
|
|
|
162
187
|
console.log(`[${label}] Lightning Address loaded from platform: ${LIGHTNING_ADDRESS}`);
|
|
163
188
|
}
|
|
164
189
|
}
|
|
165
|
-
// 3.
|
|
166
|
-
|
|
167
|
-
|
|
190
|
+
// 3. Sovereign mode: Nostr identity + relay connections + NIP-XX
|
|
191
|
+
if (SOVEREIGN) {
|
|
192
|
+
await setupSovereign(label);
|
|
193
|
+
}
|
|
194
|
+
// 4. Platform registration + heartbeat (skipped in sovereign-only mode)
|
|
195
|
+
if (!SOVEREIGN || hasApiKey()) {
|
|
196
|
+
await setupPlatform(label);
|
|
197
|
+
}
|
|
198
|
+
// 5. Async inbox poller (platform mode)
|
|
168
199
|
startInboxPoller(label);
|
|
169
200
|
// 6. P2P swarm listener
|
|
170
201
|
await startSwarmListener(label);
|
|
171
|
-
//
|
|
202
|
+
// 7. Graceful shutdown
|
|
172
203
|
setupShutdown(label);
|
|
173
|
-
|
|
204
|
+
const mode = SOVEREIGN ? 'sovereign' : (hasApiKey() ? 'platform' : 'P2P-only');
|
|
205
|
+
console.log(`[${label}] Agent ready — mode=${mode}, channels active\n`);
|
|
174
206
|
}
|
|
175
207
|
// --- 2. Platform registration ---
|
|
176
208
|
async function setupPlatform(label) {
|
|
@@ -194,6 +226,292 @@ async function setupPlatform(label) {
|
|
|
194
226
|
active: activeSessions.size > 0,
|
|
195
227
|
}));
|
|
196
228
|
}
|
|
229
|
+
// --- 2a. Sovereign Mode (AIP-0009) ---
|
|
230
|
+
async function setupSovereign(label) {
|
|
231
|
+
const agentName = state.agentName || 'sovereign-agent';
|
|
232
|
+
// 1. Load or generate Nostr keys
|
|
233
|
+
let keys = loadSovereignKeys(agentName);
|
|
234
|
+
if (!keys?.privkey) {
|
|
235
|
+
const privkey = process.env.NOSTR_PRIVKEY;
|
|
236
|
+
if (privkey) {
|
|
237
|
+
keys = {
|
|
238
|
+
...(keys || {}),
|
|
239
|
+
privkey,
|
|
240
|
+
pubkey: pubkeyFromPrivkey(privkey),
|
|
241
|
+
nwc_uri: process.env.NWC_URI,
|
|
242
|
+
relays: RELAYS,
|
|
243
|
+
lightning_address: LIGHTNING_ADDRESS || undefined,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const kp = generateKeypair();
|
|
248
|
+
keys = {
|
|
249
|
+
...(keys || {}),
|
|
250
|
+
privkey: kp.privkey,
|
|
251
|
+
pubkey: kp.pubkey,
|
|
252
|
+
nwc_uri: process.env.NWC_URI,
|
|
253
|
+
relays: RELAYS,
|
|
254
|
+
lightning_address: LIGHTNING_ADDRESS || undefined,
|
|
255
|
+
};
|
|
256
|
+
console.log(`[${label}] Generated new Nostr keypair: ${kp.pubkey}`);
|
|
257
|
+
}
|
|
258
|
+
saveSovereignKeys(agentName, keys);
|
|
259
|
+
}
|
|
260
|
+
// Apply NWC/relays from env if not already in keys
|
|
261
|
+
if (!keys.nwc_uri && process.env.NWC_URI)
|
|
262
|
+
keys.nwc_uri = process.env.NWC_URI;
|
|
263
|
+
if (!keys.relays?.length)
|
|
264
|
+
keys.relays = RELAYS;
|
|
265
|
+
if (!keys.lightning_address && LIGHTNING_ADDRESS)
|
|
266
|
+
keys.lightning_address = LIGHTNING_ADDRESS;
|
|
267
|
+
state.sovereignKeys = keys;
|
|
268
|
+
console.log(`[${label}] Sovereign identity: ${keys.pubkey}`);
|
|
269
|
+
// 2. Parse NWC URI if available
|
|
270
|
+
const nwcUri = keys.nwc_uri || process.env.NWC_URI;
|
|
271
|
+
if (nwcUri) {
|
|
272
|
+
try {
|
|
273
|
+
state.nwcParsed = parseNwcUri(nwcUri);
|
|
274
|
+
const { balance_msats } = await nwcGetBalance(state.nwcParsed);
|
|
275
|
+
console.log(`[${label}] NWC wallet connected (balance: ${Math.floor(balance_msats / 1000)} sats)`);
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
console.warn(`[${label}] NWC connection failed: ${e.message}`);
|
|
279
|
+
state.nwcParsed = null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// 3. Connect to relay pool
|
|
283
|
+
const relayUrls = keys.relays || RELAYS;
|
|
284
|
+
console.log(`[${label}] Connecting to ${relayUrls.length} relay(s)...`);
|
|
285
|
+
state.relayPool = new RelayPool(relayUrls);
|
|
286
|
+
await state.relayPool.connect();
|
|
287
|
+
console.log(`[${label}] Connected to ${state.relayPool.connectedCount} relay(s)`);
|
|
288
|
+
// 4. Publish ai.info (Kind 31340) — NIP-XX capability advertisement
|
|
289
|
+
await publishAiInfo(label);
|
|
290
|
+
// 5. Publish handler info (Kind 31990) — NIP-89 DVM capability
|
|
291
|
+
await publishHandlerInfo(label);
|
|
292
|
+
// 6. Subscribe to NIP-XX prompts (Kind 25802)
|
|
293
|
+
subscribeNipXX(label);
|
|
294
|
+
// 7. Subscribe to DVM requests (Kind 5xxx) directly from relay
|
|
295
|
+
subscribeDvmRequests(label);
|
|
296
|
+
// 8. Start sovereign heartbeat (Kind 30333 to relay)
|
|
297
|
+
startSovereignHeartbeat(label);
|
|
298
|
+
}
|
|
299
|
+
async function publishAiInfo(label) {
|
|
300
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
301
|
+
return;
|
|
302
|
+
const info = {
|
|
303
|
+
ver: 1,
|
|
304
|
+
supports_streaming: false,
|
|
305
|
+
encryption: ['nip44'],
|
|
306
|
+
supported_models: state.processor?.name ? [state.processor.name] : [],
|
|
307
|
+
default_model: state.processor?.name || 'default',
|
|
308
|
+
dvm_compatible: true,
|
|
309
|
+
dvm_kinds: [KIND],
|
|
310
|
+
pricing_hints: {
|
|
311
|
+
currency: 'BTC',
|
|
312
|
+
sats_per_prompt: SATS_PER_CHUNK * CHUNKS_PER_PAYMENT,
|
|
313
|
+
},
|
|
314
|
+
payment: {
|
|
315
|
+
methods: state.nwcParsed ? ['nwc', 'cashu'] : ['cashu'],
|
|
316
|
+
lightning_address: LIGHTNING_ADDRESS || undefined,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
const event = signEvent({
|
|
320
|
+
kind: 31340,
|
|
321
|
+
tags: [['d', 'agent-info']],
|
|
322
|
+
content: JSON.stringify(info),
|
|
323
|
+
}, state.sovereignKeys.privkey);
|
|
324
|
+
const ok = await state.relayPool.publish(event);
|
|
325
|
+
console.log(`[${label}] Published ai.info (Kind 31340): ${ok ? 'ok' : 'failed'}`);
|
|
326
|
+
}
|
|
327
|
+
async function publishHandlerInfo(label) {
|
|
328
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
329
|
+
return;
|
|
330
|
+
const agentName = state.agentName || 'sovereign-agent';
|
|
331
|
+
const content = {
|
|
332
|
+
name: agentName,
|
|
333
|
+
about: state.skill?.description || `DVM agent (kind ${KIND})`,
|
|
334
|
+
pricing: { [String(KIND)]: SATS_PER_CHUNK * CHUNKS_PER_PAYMENT },
|
|
335
|
+
};
|
|
336
|
+
const event = signEvent({
|
|
337
|
+
kind: 31990,
|
|
338
|
+
tags: [
|
|
339
|
+
['d', `${agentName}-${KIND}`],
|
|
340
|
+
['k', String(KIND)],
|
|
341
|
+
],
|
|
342
|
+
content: JSON.stringify(content),
|
|
343
|
+
}, state.sovereignKeys.privkey);
|
|
344
|
+
const ok = await state.relayPool.publish(event);
|
|
345
|
+
console.log(`[${label}] Published handler info (Kind 31990): ${ok ? 'ok' : 'failed'}`);
|
|
346
|
+
}
|
|
347
|
+
function subscribeNipXX(label) {
|
|
348
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
349
|
+
return;
|
|
350
|
+
state.relayPool.subscribe({ kinds: [25802], '#p': [state.sovereignKeys.pubkey] }, (event) => {
|
|
351
|
+
handleAiPrompt(label, event).catch(e => {
|
|
352
|
+
console.error(`[${label}] NIP-XX prompt error: ${e.message}`);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
console.log(`[${label}] Subscribed to ai.prompt (Kind 25802)`);
|
|
356
|
+
}
|
|
357
|
+
function subscribeDvmRequests(label) {
|
|
358
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
359
|
+
return;
|
|
360
|
+
// Subscribe to all DVM requests of our kind (broadcast + directed)
|
|
361
|
+
state.relayPool.subscribe({ kinds: [KIND] }, (event) => {
|
|
362
|
+
handleDvmRequest(label, event).catch(e => {
|
|
363
|
+
console.error(`[${label}] DVM request error: ${e.message}`);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
console.log(`[${label}] Subscribed to DVM requests (Kind ${KIND}) via relay`);
|
|
367
|
+
}
|
|
368
|
+
async function handleAiPrompt(label, event) {
|
|
369
|
+
if (!state.sovereignKeys || !state.relayPool || !state.processor)
|
|
370
|
+
return;
|
|
371
|
+
if (!acquireSlot()) {
|
|
372
|
+
await publishAiError(event.pubkey, event.id, 'RATE_LIMIT', 'Agent at capacity');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const clientPubkey = event.pubkey;
|
|
377
|
+
const promptId = event.id;
|
|
378
|
+
// NIP-44 decrypt
|
|
379
|
+
let content;
|
|
380
|
+
try {
|
|
381
|
+
const decrypted = await nip44Decrypt(state.sovereignKeys.privkey, clientPubkey, event.content);
|
|
382
|
+
content = JSON.parse(decrypted);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
await publishAiError(clientPubkey, promptId, 'INVALID_REQUEST', 'Failed to decrypt');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const message = content.message || content.text || '';
|
|
389
|
+
console.log(`[${label}] NIP-XX prompt from ${clientPubkey.slice(0, 8)}: "${message.slice(0, 60)}..."`);
|
|
390
|
+
// Status: thinking
|
|
391
|
+
await publishAiStatus(clientPubkey, promptId, 'thinking');
|
|
392
|
+
// Process
|
|
393
|
+
const result = await state.processor.generate({ input: message, params: content.params });
|
|
394
|
+
console.log(`[${label}] NIP-XX response: ${result.length} chars`);
|
|
395
|
+
// Build ai.response (Kind 25803)
|
|
396
|
+
const responsePayload = JSON.stringify({
|
|
397
|
+
text: result,
|
|
398
|
+
usage: { input_tokens: message.length, output_tokens: result.length },
|
|
399
|
+
});
|
|
400
|
+
const encrypted = await nip44Encrypt(state.sovereignKeys.privkey, clientPubkey, responsePayload);
|
|
401
|
+
const tags = [
|
|
402
|
+
['p', clientPubkey],
|
|
403
|
+
['e', promptId],
|
|
404
|
+
];
|
|
405
|
+
const sessionTag = event.tags.find(t => t[0] === 's');
|
|
406
|
+
if (sessionTag)
|
|
407
|
+
tags.push(sessionTag);
|
|
408
|
+
const responseEvent = signEvent({
|
|
409
|
+
kind: 25803,
|
|
410
|
+
tags,
|
|
411
|
+
content: encrypted,
|
|
412
|
+
}, state.sovereignKeys.privkey);
|
|
413
|
+
await state.relayPool.publish(responseEvent);
|
|
414
|
+
// Status: done
|
|
415
|
+
await publishAiStatus(clientPubkey, promptId, 'done');
|
|
416
|
+
console.log(`[${label}] Published ai.response (Kind 25803)`);
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
releaseSlot();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async function handleDvmRequest(label, event) {
|
|
423
|
+
if (!state.sovereignKeys || !state.relayPool || !state.processor)
|
|
424
|
+
return;
|
|
425
|
+
if (!acquireSlot())
|
|
426
|
+
return;
|
|
427
|
+
try {
|
|
428
|
+
// Parse DVM request: input is in 'i' tag
|
|
429
|
+
const inputTag = event.tags.find(t => t[0] === 'i');
|
|
430
|
+
const input = inputTag?.[1] || '';
|
|
431
|
+
if (!input) {
|
|
432
|
+
console.warn(`[${label}] DVM request ${event.id.slice(0, 8)} has no input`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
console.log(`[${label}] DVM request from ${event.pubkey.slice(0, 8)}: "${input.slice(0, 60)}..."`);
|
|
436
|
+
// Send feedback (Kind 7000)
|
|
437
|
+
const feedbackEvent = signEvent({
|
|
438
|
+
kind: 7000,
|
|
439
|
+
tags: [
|
|
440
|
+
['p', event.pubkey],
|
|
441
|
+
['e', event.id],
|
|
442
|
+
['status', 'processing'],
|
|
443
|
+
],
|
|
444
|
+
content: '',
|
|
445
|
+
}, state.sovereignKeys.privkey);
|
|
446
|
+
await state.relayPool.publish(feedbackEvent);
|
|
447
|
+
// Process
|
|
448
|
+
const result = await state.processor.generate({ input });
|
|
449
|
+
console.log(`[${label}] DVM result: ${result.length} chars`);
|
|
450
|
+
// Send result (Kind 6xxx = request kind + 1000)
|
|
451
|
+
const resultKind = KIND + 1000;
|
|
452
|
+
const resultEvent = signEvent({
|
|
453
|
+
kind: resultKind,
|
|
454
|
+
tags: [
|
|
455
|
+
['p', event.pubkey],
|
|
456
|
+
['e', event.id],
|
|
457
|
+
['request', JSON.stringify(event)],
|
|
458
|
+
],
|
|
459
|
+
content: result,
|
|
460
|
+
}, state.sovereignKeys.privkey);
|
|
461
|
+
await state.relayPool.publish(resultEvent);
|
|
462
|
+
console.log(`[${label}] Published DVM result (Kind ${resultKind}) via relay`);
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
releaseSlot();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
async function publishAiStatus(clientPubkey, promptId, status) {
|
|
469
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
470
|
+
return;
|
|
471
|
+
const payload = JSON.stringify({ state: status });
|
|
472
|
+
const encrypted = await nip44Encrypt(state.sovereignKeys.privkey, clientPubkey, payload);
|
|
473
|
+
const event = signEvent({
|
|
474
|
+
kind: 25800,
|
|
475
|
+
tags: [['p', clientPubkey], ['e', promptId]],
|
|
476
|
+
content: encrypted,
|
|
477
|
+
}, state.sovereignKeys.privkey);
|
|
478
|
+
await state.relayPool.publish(event).catch(() => { });
|
|
479
|
+
}
|
|
480
|
+
async function publishAiError(clientPubkey, promptId, code, message) {
|
|
481
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
482
|
+
return;
|
|
483
|
+
const payload = JSON.stringify({ code, message });
|
|
484
|
+
const encrypted = await nip44Encrypt(state.sovereignKeys.privkey, clientPubkey, payload);
|
|
485
|
+
const event = signEvent({
|
|
486
|
+
kind: 25805,
|
|
487
|
+
tags: [['p', clientPubkey], ['e', promptId]],
|
|
488
|
+
content: encrypted,
|
|
489
|
+
}, state.sovereignKeys.privkey);
|
|
490
|
+
await state.relayPool.publish(event).catch(() => { });
|
|
491
|
+
}
|
|
492
|
+
function startSovereignHeartbeat(label) {
|
|
493
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
494
|
+
return;
|
|
495
|
+
async function publishHeartbeat() {
|
|
496
|
+
if (!state.sovereignKeys || !state.relayPool)
|
|
497
|
+
return;
|
|
498
|
+
const event = signEvent({
|
|
499
|
+
kind: 30333,
|
|
500
|
+
tags: [
|
|
501
|
+
['d', state.sovereignKeys.pubkey],
|
|
502
|
+
['status', 'online'],
|
|
503
|
+
['capacity', String(getAvailableCapacity())],
|
|
504
|
+
['kinds', String(KIND)],
|
|
505
|
+
],
|
|
506
|
+
content: '',
|
|
507
|
+
}, state.sovereignKeys.privkey);
|
|
508
|
+
const ok = await state.relayPool.publish(event);
|
|
509
|
+
if (ok)
|
|
510
|
+
console.log(`[${label}] Heartbeat published to relay`);
|
|
511
|
+
}
|
|
512
|
+
publishHeartbeat();
|
|
513
|
+
setInterval(publishHeartbeat, 5 * 60_000);
|
|
514
|
+
}
|
|
197
515
|
// --- 3. Async Inbox Poller ---
|
|
198
516
|
function startInboxPoller(label) {
|
|
199
517
|
if (!hasApiKey())
|
|
@@ -716,6 +1034,10 @@ function setupShutdown(label) {
|
|
|
716
1034
|
if (state.swarmNode) {
|
|
717
1035
|
await state.swarmNode.destroy();
|
|
718
1036
|
}
|
|
1037
|
+
// Close relay pool (sovereign mode)
|
|
1038
|
+
if (state.relayPool) {
|
|
1039
|
+
await state.relayPool.close();
|
|
1040
|
+
}
|
|
719
1041
|
console.log(`[${label}] Goodbye`);
|
|
720
1042
|
process.exit(0);
|
|
721
1043
|
};
|
package/dist/nostr.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr primitives for sovereign mode.
|
|
3
|
+
* Key management, event signing, relay connections, NIP-44/NIP-04 encryption.
|
|
4
|
+
*
|
|
5
|
+
* Uses @noble/curves directly for signing (proven pattern from platform nwc.ts)
|
|
6
|
+
* and nostr-tools for NIP-44 (complex protocol, not worth re-implementing).
|
|
7
|
+
*/
|
|
8
|
+
export interface SovereignKeys {
|
|
9
|
+
privkey: string;
|
|
10
|
+
pubkey: string;
|
|
11
|
+
nwc_uri?: string;
|
|
12
|
+
relays?: string[];
|
|
13
|
+
lightning_address?: string;
|
|
14
|
+
api_key?: string;
|
|
15
|
+
user_id?: string;
|
|
16
|
+
username?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface NostrEvent {
|
|
19
|
+
id: string;
|
|
20
|
+
pubkey: string;
|
|
21
|
+
created_at: number;
|
|
22
|
+
kind: number;
|
|
23
|
+
tags: string[][];
|
|
24
|
+
content: string;
|
|
25
|
+
sig: string;
|
|
26
|
+
}
|
|
27
|
+
export interface UnsignedEvent {
|
|
28
|
+
kind: number;
|
|
29
|
+
tags: string[][];
|
|
30
|
+
content: string;
|
|
31
|
+
created_at?: number;
|
|
32
|
+
}
|
|
33
|
+
export declare function generateKeypair(): {
|
|
34
|
+
privkey: string;
|
|
35
|
+
pubkey: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function pubkeyFromPrivkey(privkeyHex: string): string;
|
|
38
|
+
/** Load sovereign keys for an agent from .2020117_keys (cwd then home). */
|
|
39
|
+
export declare function loadSovereignKeys(agentName?: string): SovereignKeys | null;
|
|
40
|
+
/** Save sovereign keys to .2020117_keys in current directory. */
|
|
41
|
+
export declare function saveSovereignKeys(agentName: string, keys: SovereignKeys): void;
|
|
42
|
+
export declare function signEvent(template: UnsignedEvent, privkeyHex: string): NostrEvent;
|
|
43
|
+
export declare function nip44Encrypt(privkeyHex: string, pubkeyHex: string, plaintext: string): Promise<string>;
|
|
44
|
+
export declare function nip44Decrypt(privkeyHex: string, pubkeyHex: string, ciphertext: string): Promise<string>;
|
|
45
|
+
export declare function nip04Encrypt(privkeyHex: string, pubkeyHex: string, plaintext: string): Promise<string>;
|
|
46
|
+
export declare function nip04Decrypt(privkeyHex: string, pubkeyHex: string, ciphertext: string): Promise<string>;
|
|
47
|
+
export interface RelaySubscription {
|
|
48
|
+
id: string;
|
|
49
|
+
close: () => void;
|
|
50
|
+
}
|
|
51
|
+
export declare class NostrRelay {
|
|
52
|
+
private ws;
|
|
53
|
+
private url;
|
|
54
|
+
private subs;
|
|
55
|
+
private eoseCallbacks;
|
|
56
|
+
private pendingOk;
|
|
57
|
+
private _connected;
|
|
58
|
+
private reconnectTimer;
|
|
59
|
+
private shouldReconnect;
|
|
60
|
+
constructor(url: string);
|
|
61
|
+
connect(): Promise<void>;
|
|
62
|
+
private reconnect;
|
|
63
|
+
private handleMessage;
|
|
64
|
+
publish(event: NostrEvent): Promise<boolean>;
|
|
65
|
+
subscribe(filters: Record<string, unknown>, handler: (event: NostrEvent) => void, onEose?: () => void): RelaySubscription;
|
|
66
|
+
close(): Promise<void>;
|
|
67
|
+
get connected(): boolean;
|
|
68
|
+
}
|
|
69
|
+
/** Multi-relay pool — publish to all, subscribe to all with deduplication. */
|
|
70
|
+
export declare class RelayPool {
|
|
71
|
+
private relays;
|
|
72
|
+
private urls;
|
|
73
|
+
constructor(urls: string[]);
|
|
74
|
+
connect(): Promise<void>;
|
|
75
|
+
publish(event: NostrEvent): Promise<boolean>;
|
|
76
|
+
subscribe(filters: Record<string, unknown>, handler: (event: NostrEvent) => void, onEose?: () => void): {
|
|
77
|
+
close: () => void;
|
|
78
|
+
};
|
|
79
|
+
close(): Promise<void>;
|
|
80
|
+
get connectedCount(): number;
|
|
81
|
+
}
|
package/dist/nostr.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr primitives for sovereign mode.
|
|
3
|
+
* Key management, event signing, relay connections, NIP-44/NIP-04 encryption.
|
|
4
|
+
*
|
|
5
|
+
* Uses @noble/curves directly for signing (proven pattern from platform nwc.ts)
|
|
6
|
+
* and nostr-tools for NIP-44 (complex protocol, not worth re-implementing).
|
|
7
|
+
*/
|
|
8
|
+
import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js';
|
|
9
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
10
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
|
11
|
+
import { readFileSync, writeFileSync, chmodSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { homedir } from 'os';
|
|
14
|
+
import { randomBytes } from 'crypto';
|
|
15
|
+
import WebSocket from 'ws';
|
|
16
|
+
// --- Key Management ---
|
|
17
|
+
export function generateKeypair() {
|
|
18
|
+
const sk = randomBytes(32);
|
|
19
|
+
return {
|
|
20
|
+
privkey: bytesToHex(sk),
|
|
21
|
+
pubkey: bytesToHex(schnorr.getPublicKey(sk)),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function pubkeyFromPrivkey(privkeyHex) {
|
|
25
|
+
return bytesToHex(schnorr.getPublicKey(hexToBytes(privkeyHex)));
|
|
26
|
+
}
|
|
27
|
+
/** Load sovereign keys for an agent from .2020117_keys (cwd then home). */
|
|
28
|
+
export function loadSovereignKeys(agentName) {
|
|
29
|
+
for (const dir of [process.cwd(), homedir()]) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(join(dir, '.2020117_keys'), 'utf-8');
|
|
32
|
+
const keys = JSON.parse(raw);
|
|
33
|
+
if (agentName && keys[agentName])
|
|
34
|
+
return keys[agentName];
|
|
35
|
+
if (!agentName) {
|
|
36
|
+
const first = Object.values(keys)[0];
|
|
37
|
+
if (first)
|
|
38
|
+
return first;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Save sovereign keys to .2020117_keys in current directory. */
|
|
46
|
+
export function saveSovereignKeys(agentName, keys) {
|
|
47
|
+
const filePath = join(process.cwd(), '.2020117_keys');
|
|
48
|
+
let existing = {};
|
|
49
|
+
try {
|
|
50
|
+
existing = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
existing[agentName] = keys;
|
|
54
|
+
writeFileSync(filePath, JSON.stringify(existing, null, 2));
|
|
55
|
+
try {
|
|
56
|
+
chmodSync(filePath, 0o600);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
// --- Event Signing ---
|
|
61
|
+
export function signEvent(template, privkeyHex) {
|
|
62
|
+
const sk = hexToBytes(privkeyHex);
|
|
63
|
+
const pubkey = bytesToHex(schnorr.getPublicKey(sk));
|
|
64
|
+
const created_at = template.created_at || Math.floor(Date.now() / 1000);
|
|
65
|
+
const serialized = JSON.stringify([0, pubkey, created_at, template.kind, template.tags, template.content]);
|
|
66
|
+
const id = bytesToHex(sha256(new TextEncoder().encode(serialized)));
|
|
67
|
+
const sig = bytesToHex(schnorr.sign(hexToBytes(id), sk));
|
|
68
|
+
return { id, pubkey, created_at, kind: template.kind, tags: template.tags, content: template.content, sig };
|
|
69
|
+
}
|
|
70
|
+
// --- NIP-44 Encryption (for NIP-XX messages) ---
|
|
71
|
+
let _nip44 = null;
|
|
72
|
+
async function loadNip44() {
|
|
73
|
+
if (!_nip44)
|
|
74
|
+
_nip44 = await import('nostr-tools/nip44');
|
|
75
|
+
return _nip44;
|
|
76
|
+
}
|
|
77
|
+
export async function nip44Encrypt(privkeyHex, pubkeyHex, plaintext) {
|
|
78
|
+
const nip44 = await loadNip44();
|
|
79
|
+
const ck = nip44.getConversationKey(hexToBytes(privkeyHex), pubkeyHex);
|
|
80
|
+
return nip44.encrypt(plaintext, ck);
|
|
81
|
+
}
|
|
82
|
+
export async function nip44Decrypt(privkeyHex, pubkeyHex, ciphertext) {
|
|
83
|
+
const nip44 = await loadNip44();
|
|
84
|
+
const ck = nip44.getConversationKey(hexToBytes(privkeyHex), pubkeyHex);
|
|
85
|
+
return nip44.decrypt(ciphertext, ck);
|
|
86
|
+
}
|
|
87
|
+
// --- NIP-04 Encryption (for NWC/NIP-47 compatibility) ---
|
|
88
|
+
export async function nip04Encrypt(privkeyHex, pubkeyHex, plaintext) {
|
|
89
|
+
const sharedPoint = secp256k1.getSharedSecret(hexToBytes(privkeyHex), hexToBytes('02' + pubkeyHex));
|
|
90
|
+
const sharedX = sharedPoint.slice(1, 33);
|
|
91
|
+
const key = await globalThis.crypto.subtle.importKey('raw', sharedX, { name: 'AES-CBC' }, false, ['encrypt']);
|
|
92
|
+
const iv = randomBytes(16);
|
|
93
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
94
|
+
const ciphertext = await globalThis.crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, encoded);
|
|
95
|
+
return `${Buffer.from(new Uint8Array(ciphertext)).toString('base64')}?iv=${Buffer.from(iv).toString('base64')}`;
|
|
96
|
+
}
|
|
97
|
+
export async function nip04Decrypt(privkeyHex, pubkeyHex, ciphertext) {
|
|
98
|
+
const [ctBase64, ivParam] = ciphertext.split('?iv=');
|
|
99
|
+
if (!ivParam)
|
|
100
|
+
throw new Error('Invalid NIP-04 ciphertext');
|
|
101
|
+
const sharedPoint = secp256k1.getSharedSecret(hexToBytes(privkeyHex), hexToBytes('02' + pubkeyHex));
|
|
102
|
+
const sharedX = sharedPoint.slice(1, 33);
|
|
103
|
+
const key = await globalThis.crypto.subtle.importKey('raw', sharedX, { name: 'AES-CBC' }, false, ['decrypt']);
|
|
104
|
+
const iv = new Uint8Array(Buffer.from(ivParam, 'base64'));
|
|
105
|
+
const ct = new Uint8Array(Buffer.from(ctBase64, 'base64'));
|
|
106
|
+
const plaintext = await globalThis.crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, ct);
|
|
107
|
+
return new TextDecoder().decode(plaintext);
|
|
108
|
+
}
|
|
109
|
+
export class NostrRelay {
|
|
110
|
+
ws = null;
|
|
111
|
+
url;
|
|
112
|
+
subs = new Map();
|
|
113
|
+
eoseCallbacks = new Map();
|
|
114
|
+
pendingOk = new Map();
|
|
115
|
+
_connected = false;
|
|
116
|
+
reconnectTimer = null;
|
|
117
|
+
shouldReconnect = true;
|
|
118
|
+
constructor(url) {
|
|
119
|
+
this.url = url;
|
|
120
|
+
}
|
|
121
|
+
async connect() {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
this.ws = new WebSocket(this.url);
|
|
124
|
+
const timeout = setTimeout(() => {
|
|
125
|
+
reject(new Error(`Relay timeout: ${this.url}`));
|
|
126
|
+
}, 10_000);
|
|
127
|
+
this.ws.on('open', () => {
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
this._connected = true;
|
|
130
|
+
resolve();
|
|
131
|
+
});
|
|
132
|
+
this.ws.on('message', (data) => {
|
|
133
|
+
try {
|
|
134
|
+
const msg = JSON.parse(data.toString());
|
|
135
|
+
this.handleMessage(msg);
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
});
|
|
139
|
+
this.ws.on('close', () => {
|
|
140
|
+
this._connected = false;
|
|
141
|
+
if (this.shouldReconnect) {
|
|
142
|
+
this.reconnectTimer = setTimeout(() => this.reconnect(), 5000);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
this.ws.on('error', (err) => {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
if (!this._connected)
|
|
148
|
+
reject(err);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async reconnect() {
|
|
153
|
+
try {
|
|
154
|
+
await this.connect();
|
|
155
|
+
// Re-subscribe after reconnect
|
|
156
|
+
for (const [id, handler] of this.subs) {
|
|
157
|
+
// Can't re-send original filters, but the subscription handler is preserved
|
|
158
|
+
// Caller should re-subscribe if needed
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
}
|
|
163
|
+
handleMessage(msg) {
|
|
164
|
+
if (msg[0] === 'EVENT') {
|
|
165
|
+
const handler = this.subs.get(msg[1]);
|
|
166
|
+
if (handler)
|
|
167
|
+
handler(msg[2]);
|
|
168
|
+
}
|
|
169
|
+
else if (msg[0] === 'OK') {
|
|
170
|
+
const cb = this.pendingOk.get(msg[1]);
|
|
171
|
+
if (cb) {
|
|
172
|
+
clearTimeout(cb.timer);
|
|
173
|
+
this.pendingOk.delete(msg[1]);
|
|
174
|
+
cb.resolve(msg[2]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (msg[0] === 'EOSE') {
|
|
178
|
+
const cb = this.eoseCallbacks.get(msg[1]);
|
|
179
|
+
if (cb) {
|
|
180
|
+
this.eoseCallbacks.delete(msg[1]);
|
|
181
|
+
cb();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async publish(event) {
|
|
186
|
+
if (!this.ws || !this._connected)
|
|
187
|
+
throw new Error('Not connected');
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
this.pendingOk.delete(event.id);
|
|
191
|
+
resolve(false);
|
|
192
|
+
}, 10_000);
|
|
193
|
+
this.pendingOk.set(event.id, { resolve, timer });
|
|
194
|
+
this.ws.send(JSON.stringify(['EVENT', event]));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
subscribe(filters, handler, onEose) {
|
|
198
|
+
if (!this.ws || !this._connected)
|
|
199
|
+
throw new Error('Not connected');
|
|
200
|
+
const id = randomBytes(4).toString('hex');
|
|
201
|
+
this.subs.set(id, handler);
|
|
202
|
+
if (onEose)
|
|
203
|
+
this.eoseCallbacks.set(id, onEose);
|
|
204
|
+
this.ws.send(JSON.stringify(['REQ', id, filters]));
|
|
205
|
+
return {
|
|
206
|
+
id,
|
|
207
|
+
close: () => {
|
|
208
|
+
this.subs.delete(id);
|
|
209
|
+
this.eoseCallbacks.delete(id);
|
|
210
|
+
if (this.ws && this._connected) {
|
|
211
|
+
try {
|
|
212
|
+
this.ws.send(JSON.stringify(['CLOSE', id]));
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async close() {
|
|
220
|
+
this.shouldReconnect = false;
|
|
221
|
+
if (this.reconnectTimer)
|
|
222
|
+
clearTimeout(this.reconnectTimer);
|
|
223
|
+
for (const [id] of this.subs) {
|
|
224
|
+
if (this.ws && this._connected) {
|
|
225
|
+
try {
|
|
226
|
+
this.ws.send(JSON.stringify(['CLOSE', id]));
|
|
227
|
+
}
|
|
228
|
+
catch { }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
this.subs.clear();
|
|
232
|
+
for (const [, cb] of this.pendingOk)
|
|
233
|
+
clearTimeout(cb.timer);
|
|
234
|
+
this.pendingOk.clear();
|
|
235
|
+
if (this.ws) {
|
|
236
|
+
this.ws.close();
|
|
237
|
+
this.ws = null;
|
|
238
|
+
}
|
|
239
|
+
this._connected = false;
|
|
240
|
+
}
|
|
241
|
+
get connected() { return this._connected; }
|
|
242
|
+
}
|
|
243
|
+
/** Multi-relay pool — publish to all, subscribe to all with deduplication. */
|
|
244
|
+
export class RelayPool {
|
|
245
|
+
relays = [];
|
|
246
|
+
urls;
|
|
247
|
+
constructor(urls) {
|
|
248
|
+
this.urls = urls;
|
|
249
|
+
}
|
|
250
|
+
async connect() {
|
|
251
|
+
const results = await Promise.allSettled(this.urls.map(async (url) => {
|
|
252
|
+
const relay = new NostrRelay(url);
|
|
253
|
+
await relay.connect();
|
|
254
|
+
return relay;
|
|
255
|
+
}));
|
|
256
|
+
for (const r of results) {
|
|
257
|
+
if (r.status === 'fulfilled')
|
|
258
|
+
this.relays.push(r.value);
|
|
259
|
+
}
|
|
260
|
+
if (this.relays.length === 0)
|
|
261
|
+
throw new Error('Failed to connect to any relay');
|
|
262
|
+
}
|
|
263
|
+
async publish(event) {
|
|
264
|
+
const results = await Promise.allSettled(this.relays.map(r => r.publish(event)));
|
|
265
|
+
return results.some(r => r.status === 'fulfilled' && r.value);
|
|
266
|
+
}
|
|
267
|
+
subscribe(filters, handler, onEose) {
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
const subs = [];
|
|
270
|
+
let eoseCount = 0;
|
|
271
|
+
for (const relay of this.relays) {
|
|
272
|
+
try {
|
|
273
|
+
const sub = relay.subscribe(filters, (event) => {
|
|
274
|
+
if (seen.has(event.id))
|
|
275
|
+
return;
|
|
276
|
+
seen.add(event.id);
|
|
277
|
+
handler(event);
|
|
278
|
+
}, onEose ? () => {
|
|
279
|
+
eoseCount++;
|
|
280
|
+
if (eoseCount >= this.relays.length)
|
|
281
|
+
onEose();
|
|
282
|
+
} : undefined);
|
|
283
|
+
subs.push(sub);
|
|
284
|
+
}
|
|
285
|
+
catch { }
|
|
286
|
+
}
|
|
287
|
+
return { close: () => subs.forEach(s => s.close()) };
|
|
288
|
+
}
|
|
289
|
+
async close() {
|
|
290
|
+
await Promise.all(this.relays.map(r => r.close()));
|
|
291
|
+
}
|
|
292
|
+
get connectedCount() {
|
|
293
|
+
return this.relays.filter(r => r.connected).length;
|
|
294
|
+
}
|
|
295
|
+
}
|
package/dist/nwc.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone NWC (NIP-47) client for sovereign mode.
|
|
3
|
+
* Agent directly manages its own wallet without platform proxy.
|
|
4
|
+
*/
|
|
5
|
+
export interface NwcParsed {
|
|
6
|
+
walletPubkey: string;
|
|
7
|
+
relayUrl: string;
|
|
8
|
+
secret: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseNwcUri(uri: string): NwcParsed;
|
|
11
|
+
export declare function nwcGetBalance(parsed: NwcParsed): Promise<{
|
|
12
|
+
balance_msats: number;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function nwcPayInvoice(parsed: NwcParsed, bolt11: string): Promise<{
|
|
15
|
+
preimage: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function nwcMakeInvoice(parsed: NwcParsed, amountMsats: number, description?: string): Promise<{
|
|
18
|
+
bolt11: string;
|
|
19
|
+
payment_hash: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function nwcGetInfo(parsed: NwcParsed): Promise<{
|
|
22
|
+
supported_methods: string[];
|
|
23
|
+
}>;
|
|
24
|
+
/** Resolve a Lightning Address to a bolt11 invoice and pay via NWC. */
|
|
25
|
+
export declare function nwcPayLightningAddress(parsed: NwcParsed, address: string, amountSats: number): Promise<{
|
|
26
|
+
preimage: string;
|
|
27
|
+
}>;
|
package/dist/nwc.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone NWC (NIP-47) client for sovereign mode.
|
|
3
|
+
* Agent directly manages its own wallet without platform proxy.
|
|
4
|
+
*/
|
|
5
|
+
import { signEvent, nip04Encrypt, nip04Decrypt } from './nostr.js';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
// --- Parse ---
|
|
8
|
+
export function parseNwcUri(uri) {
|
|
9
|
+
if (!uri.startsWith('nostr+walletconnect://')) {
|
|
10
|
+
throw new Error('Invalid NWC URI: must start with nostr+walletconnect://');
|
|
11
|
+
}
|
|
12
|
+
const withoutScheme = uri.slice('nostr+walletconnect://'.length);
|
|
13
|
+
const questionIdx = withoutScheme.indexOf('?');
|
|
14
|
+
if (questionIdx === -1)
|
|
15
|
+
throw new Error('Invalid NWC URI: missing query parameters');
|
|
16
|
+
const walletPubkey = withoutScheme.slice(0, questionIdx);
|
|
17
|
+
if (!/^[0-9a-f]{64}$/.test(walletPubkey)) {
|
|
18
|
+
throw new Error('Invalid NWC URI: wallet pubkey must be 64 hex chars');
|
|
19
|
+
}
|
|
20
|
+
const params = new URLSearchParams(withoutScheme.slice(questionIdx + 1));
|
|
21
|
+
const relayUrl = params.get('relay');
|
|
22
|
+
const secret = params.get('secret');
|
|
23
|
+
if (!relayUrl)
|
|
24
|
+
throw new Error('Invalid NWC URI: missing relay parameter');
|
|
25
|
+
if (!secret || !/^[0-9a-f]{64}$/.test(secret)) {
|
|
26
|
+
throw new Error('Invalid NWC URI: missing or invalid secret parameter');
|
|
27
|
+
}
|
|
28
|
+
return { walletPubkey, relayUrl, secret };
|
|
29
|
+
}
|
|
30
|
+
// --- NWC Request Engine ---
|
|
31
|
+
async function nwcRequest(parsed, method, params = {}) {
|
|
32
|
+
const { walletPubkey, relayUrl, secret } = parsed;
|
|
33
|
+
const content = JSON.stringify({ method, params });
|
|
34
|
+
const encrypted = await nip04Encrypt(secret, walletPubkey, content);
|
|
35
|
+
// Sign with the NWC secret key (it IS a Nostr private key)
|
|
36
|
+
const event = signEvent({
|
|
37
|
+
kind: 23194,
|
|
38
|
+
tags: [['p', walletPubkey]],
|
|
39
|
+
content: encrypted,
|
|
40
|
+
}, secret);
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const timeout = setTimeout(() => {
|
|
43
|
+
try {
|
|
44
|
+
ws.close();
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
reject(new Error(`NWC ${method} timeout (30s)`));
|
|
48
|
+
}, 30_000);
|
|
49
|
+
const ws = new WebSocket(relayUrl);
|
|
50
|
+
ws.on('open', () => {
|
|
51
|
+
const subId = Math.random().toString(36).slice(2, 10);
|
|
52
|
+
// Subscribe for wallet response
|
|
53
|
+
ws.send(JSON.stringify(['REQ', subId, {
|
|
54
|
+
kinds: [23195],
|
|
55
|
+
authors: [walletPubkey],
|
|
56
|
+
'#e': [event.id],
|
|
57
|
+
limit: 1,
|
|
58
|
+
}]));
|
|
59
|
+
// Send our request
|
|
60
|
+
ws.send(JSON.stringify(['EVENT', event]));
|
|
61
|
+
});
|
|
62
|
+
ws.on('message', async (data) => {
|
|
63
|
+
try {
|
|
64
|
+
const msg = JSON.parse(data.toString());
|
|
65
|
+
if (msg[0] === 'EVENT' && msg[2]?.kind === 23195) {
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
const decrypted = await nip04Decrypt(secret, walletPubkey, msg[2].content);
|
|
68
|
+
const result = JSON.parse(decrypted);
|
|
69
|
+
ws.close();
|
|
70
|
+
if (result.error) {
|
|
71
|
+
reject(new Error(`NWC ${method}: ${result.error.message || result.error.code}`));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
resolve(result.result);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
});
|
|
80
|
+
ws.on('error', () => {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
reject(new Error('NWC WebSocket connection failed'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// --- Public API ---
|
|
87
|
+
export async function nwcGetBalance(parsed) {
|
|
88
|
+
const result = await nwcRequest(parsed, 'get_balance');
|
|
89
|
+
return { balance_msats: result?.balance || 0 };
|
|
90
|
+
}
|
|
91
|
+
export async function nwcPayInvoice(parsed, bolt11) {
|
|
92
|
+
const result = await nwcRequest(parsed, 'pay_invoice', { invoice: bolt11 });
|
|
93
|
+
return { preimage: result?.preimage || '' };
|
|
94
|
+
}
|
|
95
|
+
export async function nwcMakeInvoice(parsed, amountMsats, description) {
|
|
96
|
+
const result = await nwcRequest(parsed, 'make_invoice', {
|
|
97
|
+
amount: amountMsats,
|
|
98
|
+
description: description || '',
|
|
99
|
+
});
|
|
100
|
+
return { bolt11: result?.invoice || '', payment_hash: result?.payment_hash || '' };
|
|
101
|
+
}
|
|
102
|
+
export async function nwcGetInfo(parsed) {
|
|
103
|
+
const result = await nwcRequest(parsed, 'get_info');
|
|
104
|
+
return { supported_methods: result?.methods || [] };
|
|
105
|
+
}
|
|
106
|
+
/** Resolve a Lightning Address to a bolt11 invoice and pay via NWC. */
|
|
107
|
+
export async function nwcPayLightningAddress(parsed, address, amountSats) {
|
|
108
|
+
const [user, domain] = address.split('@');
|
|
109
|
+
if (!user || !domain)
|
|
110
|
+
throw new Error(`Invalid Lightning Address: ${address}`);
|
|
111
|
+
// LNURL-pay step 1: fetch metadata
|
|
112
|
+
const metaResp = await fetch(`https://${domain}/.well-known/lnurlp/${user}`);
|
|
113
|
+
if (!metaResp.ok)
|
|
114
|
+
throw new Error(`LNURL fetch failed (${metaResp.status})`);
|
|
115
|
+
const meta = await metaResp.json();
|
|
116
|
+
if (meta.tag !== 'payRequest')
|
|
117
|
+
throw new Error(`Unexpected LNURL tag: ${meta.tag}`);
|
|
118
|
+
const amountMsats = amountSats * 1000;
|
|
119
|
+
if (amountMsats < meta.minSendable || amountMsats > meta.maxSendable) {
|
|
120
|
+
throw new Error(`Amount ${amountSats} sats out of range [${meta.minSendable / 1000}-${meta.maxSendable / 1000}]`);
|
|
121
|
+
}
|
|
122
|
+
// LNURL-pay step 2: get invoice
|
|
123
|
+
const sep = meta.callback.includes('?') ? '&' : '?';
|
|
124
|
+
const invoiceResp = await fetch(`${meta.callback}${sep}amount=${amountMsats}`);
|
|
125
|
+
if (!invoiceResp.ok)
|
|
126
|
+
throw new Error(`LNURL callback failed (${invoiceResp.status})`);
|
|
127
|
+
const invoiceData = await invoiceResp.json();
|
|
128
|
+
if (!invoiceData.pr)
|
|
129
|
+
throw new Error('No invoice returned from LNURL callback');
|
|
130
|
+
// Step 3: pay via NWC
|
|
131
|
+
return nwcPayInvoice(parsed, invoiceData.pr);
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "2020117-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "2020117 agent runtime — API polling + Hyperswarm P2P + Cashu/Lightning payments",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "2020117 agent runtime — API polling + Hyperswarm P2P + Sovereign Nostr mode + Cashu/Lightning payments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"2020117-agent": "./dist/agent.js",
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"./swarm": "./dist/swarm.js",
|
|
16
16
|
"./cashu": "./dist/cashu.js",
|
|
17
17
|
"./lightning": "./dist/clink.js",
|
|
18
|
-
"./api": "./dist/api.js"
|
|
18
|
+
"./api": "./dist/api.js",
|
|
19
|
+
"./nostr": "./dist/nostr.js",
|
|
20
|
+
"./nwc": "./dist/nwc.js"
|
|
19
21
|
},
|
|
20
22
|
"scripts": {
|
|
21
23
|
"agent": "node dist/agent.js",
|
|
@@ -28,7 +30,10 @@
|
|
|
28
30
|
},
|
|
29
31
|
"dependencies": {
|
|
30
32
|
"@cashu/cashu-ts": "^2.5.3",
|
|
33
|
+
"@noble/curves": "^2.0.1",
|
|
34
|
+
"@noble/hashes": "^2.0.1",
|
|
31
35
|
"hyperswarm": "^4.17.0",
|
|
36
|
+
"nostr-tools": "^2.23.3",
|
|
32
37
|
"ws": "^8.19.0"
|
|
33
38
|
},
|
|
34
39
|
"devDependencies": {
|