lightning-agent 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,10 +1,28 @@
1
1
  'use strict';
2
2
 
3
- const { createWallet, parseNwcUrl, decodeBolt11, NWCWallet } = require('./wallet');
3
+ const { createWallet, parseNwcUrl, decodeBolt11, resolveLightningAddress, NWCWallet } = require('./wallet');
4
+ const { createAuthServer, signAuth, authenticate } = require('./auth');
5
+ const { createEscrowManager, State: EscrowState } = require('./escrow');
6
+ const { createStreamProvider, createStreamClient } = require('./stream');
4
7
 
5
8
  module.exports = {
9
+ // Wallet (v0.1.0)
6
10
  createWallet,
7
11
  parseNwcUrl,
8
12
  decodeBolt11,
9
- NWCWallet
13
+ resolveLightningAddress,
14
+ NWCWallet,
15
+
16
+ // Auth (v0.3.0)
17
+ createAuthServer,
18
+ signAuth,
19
+ authenticate,
20
+
21
+ // Escrow (v0.3.0)
22
+ createEscrowManager,
23
+ EscrowState,
24
+
25
+ // Streaming payments (v0.3.0)
26
+ createStreamProvider,
27
+ createStreamClient
10
28
  };
package/lib/stream.js ADDED
@@ -0,0 +1,477 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Streaming sats — pay-per-token Lightning micropayments.
5
+ *
6
+ * Provider: generates content, gates delivery on payment, emits micro-invoices.
7
+ * Client: receives content, auto-pays invoices to keep the stream alive.
8
+ *
9
+ * This is what Lightning was designed for: payments too small for any other system.
10
+ *
11
+ * Transport: HTTP Server-Sent Events (SSE). Provider streams content + invoices,
12
+ * client POSTs preimages to prove payment and unlock the next batch.
13
+ *
14
+ * @example
15
+ * // Provider side
16
+ * const provider = createStreamProvider(wallet, {
17
+ * satsPerBatch: 2,
18
+ * tokensPerBatch: 100,
19
+ * maxBatches: 50
20
+ * });
21
+ *
22
+ * // In HTTP handler:
23
+ * provider.handleRequest(req, res, async function* () {
24
+ * for await (const token of generateTokens(prompt)) {
25
+ * yield token;
26
+ * }
27
+ * });
28
+ *
29
+ * // Client side
30
+ * const client = createStreamClient(wallet);
31
+ * const stream = await client.stream('https://api.example.com/generate', {
32
+ * body: { prompt: 'Hello' },
33
+ * maxSats: 100
34
+ * });
35
+ * for await (const chunk of stream) {
36
+ * process.stdout.write(chunk);
37
+ * }
38
+ */
39
+
40
+ const crypto = require('crypto');
41
+ const { EventEmitter } = require('events');
42
+
43
+ // ─── Stream Provider (server side) ───
44
+
45
+ /**
46
+ * Create a streaming payment provider.
47
+ *
48
+ * @param {NWCWallet} wallet - Provider's wallet for invoice generation
49
+ * @param {object} [opts]
50
+ * @param {number} [opts.satsPerBatch=1] - Sats charged per batch
51
+ * @param {number} [opts.tokensPerBatch=50] - Tokens per batch before next invoice
52
+ * @param {number} [opts.maxBatches=100] - Maximum batches per stream
53
+ * @param {number} [opts.paymentTimeoutMs=30000] - Time to wait for payment per batch
54
+ * @param {number} [opts.invoiceExpiryS=120] - Invoice expiry in seconds
55
+ * @returns {StreamProvider}
56
+ */
57
+ function createStreamProvider(wallet, opts = {}) {
58
+ const satsPerBatch = opts.satsPerBatch || 1;
59
+ const tokensPerBatch = opts.tokensPerBatch || 50;
60
+ const maxBatches = opts.maxBatches || 100;
61
+ const paymentTimeoutMs = opts.paymentTimeoutMs || 30000;
62
+ const invoiceExpiryS = opts.invoiceExpiryS || 120;
63
+ const sessions = new Map();
64
+
65
+ return {
66
+ /**
67
+ * Handle a streaming request over HTTP SSE.
68
+ *
69
+ * Protocol:
70
+ * Server sends SSE events:
71
+ * - event: content data: { tokens: "...", batchIndex: N }
72
+ * - event: invoice data: { invoice: "lnbc...", paymentHash: "...", batchIndex: N, sats: N }
73
+ * - event: done data: { totalBatches: N, totalSats: N, totalTokens: N }
74
+ * - event: error data: { message: "..." }
75
+ *
76
+ * Client proves payment by POSTing to the same URL:
77
+ * { sessionId, preimage }
78
+ *
79
+ * @param {http.IncomingMessage} req
80
+ * @param {http.ServerResponse} res
81
+ * @param {AsyncGenerator|function} generator - Async generator yielding tokens/strings
82
+ * @param {object} [streamOpts]
83
+ * @param {number} [streamOpts.firstBatchFree=true] - First batch free (preview)
84
+ */
85
+ async handleRequest(req, res, generator, streamOpts = {}) {
86
+ const firstBatchFree = streamOpts.firstBatchFree !== false;
87
+
88
+ // Handle payment proof POST
89
+ if (req.method === 'POST') {
90
+ return this._handlePayment(req, res);
91
+ }
92
+
93
+ // SSE setup
94
+ res.writeHead(200, {
95
+ 'Content-Type': 'text/event-stream',
96
+ 'Cache-Control': 'no-cache',
97
+ 'Connection': 'keep-alive',
98
+ 'X-Accel-Buffering': 'no'
99
+ });
100
+
101
+ const sessionId = crypto.randomBytes(16).toString('hex');
102
+ const session = {
103
+ id: sessionId,
104
+ batchIndex: 0,
105
+ totalTokens: 0,
106
+ totalSats: 0,
107
+ pendingPayment: null,
108
+ paid: new Set(),
109
+ closed: false
110
+ };
111
+ sessions.set(sessionId, session);
112
+
113
+ // Send session ID
114
+ sendSSE(res, 'session', { sessionId });
115
+
116
+ try {
117
+ const gen = typeof generator === 'function' ? generator() : generator;
118
+ let buffer = '';
119
+ let tokenCount = 0;
120
+
121
+ for await (const token of gen) {
122
+ if (session.closed) break;
123
+
124
+ buffer += token;
125
+ tokenCount++;
126
+
127
+ // Batch full — flush and gate
128
+ if (tokenCount >= tokensPerBatch) {
129
+ session.batchIndex++;
130
+ session.totalTokens += tokenCount;
131
+
132
+ // Send content
133
+ sendSSE(res, 'content', {
134
+ tokens: buffer,
135
+ batchIndex: session.batchIndex,
136
+ tokenCount
137
+ });
138
+
139
+ buffer = '';
140
+ tokenCount = 0;
141
+
142
+ // Check if we've hit max batches
143
+ if (session.batchIndex >= maxBatches) {
144
+ sendSSE(res, 'done', {
145
+ reason: 'max_batches',
146
+ totalBatches: session.batchIndex,
147
+ totalSats: session.totalSats,
148
+ totalTokens: session.totalTokens
149
+ });
150
+ break;
151
+ }
152
+
153
+ // Gate: require payment for next batch (skip for first if free)
154
+ const needsPayment = !(firstBatchFree && session.batchIndex === 1);
155
+ if (needsPayment) {
156
+ const invoice = await wallet.createInvoice({
157
+ amountSats: satsPerBatch,
158
+ description: `Stream batch ${session.batchIndex + 1}`,
159
+ expiry: invoiceExpiryS
160
+ });
161
+
162
+ session.pendingPayment = {
163
+ paymentHash: invoice.paymentHash,
164
+ batchIndex: session.batchIndex + 1,
165
+ invoice: invoice.invoice
166
+ };
167
+
168
+ sendSSE(res, 'invoice', {
169
+ invoice: invoice.invoice,
170
+ paymentHash: invoice.paymentHash,
171
+ batchIndex: session.batchIndex + 1,
172
+ sats: satsPerBatch
173
+ });
174
+
175
+ // Wait for payment
176
+ const paid = await this._waitForPayment(session, paymentTimeoutMs);
177
+ if (!paid) {
178
+ sendSSE(res, 'paused', {
179
+ reason: 'payment_timeout',
180
+ batchIndex: session.batchIndex,
181
+ totalSats: session.totalSats,
182
+ resume: `POST with { sessionId: "${sessionId}", preimage: "..." }`
183
+ });
184
+ // Don't end — client can still pay and we'll resume
185
+ // But stop generating for now
186
+ break;
187
+ }
188
+ session.totalSats += satsPerBatch;
189
+ }
190
+ }
191
+ }
192
+
193
+ // Flush remaining
194
+ if (buffer.length > 0 && !session.closed) {
195
+ session.batchIndex++;
196
+ session.totalTokens += tokenCount;
197
+ sendSSE(res, 'content', {
198
+ tokens: buffer,
199
+ batchIndex: session.batchIndex,
200
+ tokenCount
201
+ });
202
+ }
203
+
204
+ if (!session.closed) {
205
+ sendSSE(res, 'done', {
206
+ reason: 'complete',
207
+ totalBatches: session.batchIndex,
208
+ totalSats: session.totalSats,
209
+ totalTokens: session.totalTokens
210
+ });
211
+ }
212
+
213
+ } catch (err) {
214
+ sendSSE(res, 'error', { message: err.message });
215
+ } finally {
216
+ session.closed = true;
217
+ res.end();
218
+ // Cleanup after a delay (allow late payments)
219
+ setTimeout(() => sessions.delete(sessionId), 5 * 60 * 1000);
220
+ }
221
+ },
222
+
223
+ /**
224
+ * Handle payment proof POST.
225
+ * @private
226
+ */
227
+ _handlePayment(req, res) {
228
+ return new Promise((resolve) => {
229
+ let body = '';
230
+ req.on('data', d => body += d);
231
+ req.on('end', () => {
232
+ try {
233
+ const data = JSON.parse(body);
234
+ const session = sessions.get(data.sessionId);
235
+ if (!session) {
236
+ res.writeHead(404, { 'Content-Type': 'application/json' });
237
+ res.end(JSON.stringify({ error: 'Unknown session' }));
238
+ return resolve();
239
+ }
240
+
241
+ if (data.preimage && session.pendingPayment) {
242
+ // Verify preimage matches payment hash
243
+ const hash = crypto.createHash('sha256')
244
+ .update(Buffer.from(data.preimage, 'hex'))
245
+ .digest('hex');
246
+
247
+ if (hash === session.pendingPayment.paymentHash) {
248
+ session.paid.add(session.pendingPayment.batchIndex);
249
+ session.pendingPayment = null;
250
+ res.writeHead(200, { 'Content-Type': 'application/json' });
251
+ res.end(JSON.stringify({ status: 'ok', batchUnlocked: true }));
252
+ } else {
253
+ res.writeHead(400, { 'Content-Type': 'application/json' });
254
+ res.end(JSON.stringify({ error: 'Invalid preimage' }));
255
+ }
256
+ } else {
257
+ res.writeHead(400, { 'Content-Type': 'application/json' });
258
+ res.end(JSON.stringify({ error: 'No pending payment or missing preimage' }));
259
+ }
260
+ resolve();
261
+ } catch (err) {
262
+ res.writeHead(400, { 'Content-Type': 'application/json' });
263
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
264
+ resolve();
265
+ }
266
+ });
267
+ });
268
+ },
269
+
270
+ /**
271
+ * Wait for a payment to be confirmed (via preimage POST or wallet polling).
272
+ * @private
273
+ */
274
+ async _waitForPayment(session, timeoutMs) {
275
+ if (!session.pendingPayment) return true;
276
+
277
+ const start = Date.now();
278
+ const hash = session.pendingPayment.paymentHash;
279
+ const batchIdx = session.pendingPayment.batchIndex;
280
+
281
+ // Poll: check if preimage was POSTed, or check wallet
282
+ while (Date.now() - start < timeoutMs) {
283
+ // Check if client POSTed the preimage
284
+ if (session.paid.has(batchIdx)) {
285
+ return true;
286
+ }
287
+
288
+ // Also check wallet directly
289
+ try {
290
+ const result = await wallet.waitForPayment(hash, {
291
+ timeoutMs: 3000,
292
+ pollIntervalMs: 1000
293
+ });
294
+ if (result.paid) {
295
+ session.paid.add(batchIdx);
296
+ session.pendingPayment = null;
297
+ return true;
298
+ }
299
+ } catch {
300
+ // Not supported or timeout — keep polling preimage POST
301
+ }
302
+
303
+ await new Promise(r => setTimeout(r, 1000));
304
+ }
305
+
306
+ return false;
307
+ },
308
+
309
+ /** Active session count */
310
+ get activeSessions() {
311
+ return [...sessions.values()].filter(s => !s.closed).length;
312
+ }
313
+ };
314
+ }
315
+
316
+ // ─── Stream Client (consumer side) ───
317
+
318
+ /**
319
+ * Create a streaming payment client.
320
+ *
321
+ * @param {NWCWallet} wallet - Client's wallet for paying invoices
322
+ * @param {object} [opts]
323
+ * @param {number} [opts.maxSats=1000] - Maximum sats to spend per stream
324
+ * @param {boolean} [opts.autoPay=true] - Automatically pay invoices
325
+ * @returns {StreamClient}
326
+ */
327
+ function createStreamClient(wallet, opts = {}) {
328
+ const maxSats = opts.maxSats || 1000;
329
+ const autoPay = opts.autoPay !== false;
330
+
331
+ return {
332
+ /**
333
+ * Open a streaming connection and iterate over content.
334
+ * Auto-pays invoices as they arrive (within maxSats budget).
335
+ *
336
+ * @param {string} url - Provider's streaming endpoint
337
+ * @param {object} [reqOpts]
338
+ * @param {object} [reqOpts.body] - Request body (sent as JSON)
339
+ * @param {object} [reqOpts.headers] - Additional headers
340
+ * @param {number} [reqOpts.maxSats] - Override max sats for this stream
341
+ * @returns {AsyncGenerator<string>} - Yields content chunks
342
+ *
343
+ * @example
344
+ * const client = createStreamClient(wallet, { maxSats: 500 });
345
+ * for await (const text of client.stream('https://api.example.com/stream', {
346
+ * body: { prompt: 'Explain Lightning Network' }
347
+ * })) {
348
+ * process.stdout.write(text);
349
+ * }
350
+ */
351
+ async *stream(url, reqOpts = {}) {
352
+ const budget = reqOpts.maxSats || maxSats;
353
+ let spent = 0;
354
+ let sessionId = null;
355
+
356
+ // Open SSE connection
357
+ const headers = {
358
+ 'Accept': 'text/event-stream',
359
+ ...(reqOpts.headers || {})
360
+ };
361
+
362
+ // If body provided, make initial POST to get stream URL, then GET SSE
363
+ // For simplicity, we support GET with query params or POST that returns SSE
364
+ const fetchOpts = { headers };
365
+ if (reqOpts.body) {
366
+ fetchOpts.method = 'POST';
367
+ fetchOpts.headers['Content-Type'] = 'application/json';
368
+ fetchOpts.body = JSON.stringify(reqOpts.body);
369
+ }
370
+
371
+ const response = await fetch(url, fetchOpts);
372
+ if (!response.ok) {
373
+ throw new Error(`Stream request failed: ${response.status}`);
374
+ }
375
+
376
+ const reader = response.body.getReader();
377
+ const decoder = new TextDecoder();
378
+ let buffer = '';
379
+
380
+ try {
381
+ while (true) {
382
+ const { done, value } = await reader.read();
383
+ if (done) break;
384
+
385
+ buffer += decoder.decode(value, { stream: true });
386
+ const lines = buffer.split('\n');
387
+ buffer = lines.pop(); // Keep incomplete line
388
+
389
+ let eventType = null;
390
+ let eventData = '';
391
+
392
+ for (const line of lines) {
393
+ if (line.startsWith('event: ')) {
394
+ eventType = line.slice(7).trim();
395
+ } else if (line.startsWith('data: ')) {
396
+ eventData = line.slice(6);
397
+ } else if (line === '' && eventType && eventData) {
398
+ // Process complete event
399
+ const data = JSON.parse(eventData);
400
+
401
+ switch (eventType) {
402
+ case 'session':
403
+ sessionId = data.sessionId;
404
+ break;
405
+
406
+ case 'content':
407
+ yield data.tokens;
408
+ break;
409
+
410
+ case 'invoice':
411
+ if (autoPay && spent + data.sats <= budget) {
412
+ try {
413
+ const payResult = await wallet.payInvoice(data.invoice);
414
+ spent += data.sats;
415
+
416
+ // POST preimage back to provider
417
+ if (sessionId && payResult.preimage) {
418
+ fetch(url, {
419
+ method: 'POST',
420
+ headers: { 'Content-Type': 'application/json' },
421
+ body: JSON.stringify({
422
+ sessionId,
423
+ preimage: payResult.preimage
424
+ })
425
+ }).catch(() => {}); // Best effort
426
+ }
427
+ } catch (err) {
428
+ // Payment failed — stream will pause
429
+ console.error('Stream payment failed:', err.message);
430
+ }
431
+ } else {
432
+ // Budget exceeded — stop paying
433
+ console.warn(`Stream budget exhausted (${spent}/${budget} sats)`);
434
+ }
435
+ break;
436
+
437
+ case 'done':
438
+ return; // Stream complete
439
+
440
+ case 'error':
441
+ throw new Error(data.message || 'Stream error');
442
+
443
+ case 'paused':
444
+ // Stream paused for payment — will resume if we pay
445
+ break;
446
+ }
447
+
448
+ eventType = null;
449
+ eventData = '';
450
+ }
451
+ }
452
+ }
453
+ } finally {
454
+ reader.releaseLock();
455
+ }
456
+ },
457
+
458
+ /**
459
+ * Get spending stats for a completed stream.
460
+ */
461
+ get budget() {
462
+ return { maxSats };
463
+ }
464
+ };
465
+ }
466
+
467
+ // ─── Helpers ───
468
+
469
+ function sendSSE(res, event, data) {
470
+ if (res.writableEnded) return;
471
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
472
+ }
473
+
474
+ module.exports = {
475
+ createStreamProvider,
476
+ createStreamClient
477
+ };
package/lib/wallet.js CHANGED
@@ -96,6 +96,57 @@ function decodeBolt11(invoice) {
96
96
  };
97
97
  }
98
98
 
99
+ // ─── Lightning Address (LNURL-pay) resolver ───
100
+
101
+ /**
102
+ * Resolve a Lightning address to a bolt11 invoice via LNURL-pay.
103
+ * Lightning address format: user@domain → https://domain/.well-known/lnurlp/user
104
+ * @param {string} address - Lightning address (user@domain)
105
+ * @param {number} amountSats - Amount in satoshis
106
+ * @param {string} [comment] - Optional payer comment
107
+ * @returns {Promise<{ invoice: string, minSats: number, maxSats: number }>}
108
+ */
109
+ async function resolveLightningAddress(address, amountSats, comment) {
110
+ const [name, domain] = address.split('@');
111
+ if (!name || !domain) throw new Error('Invalid Lightning address: ' + address);
112
+
113
+ // Step 1: Fetch LNURL-pay metadata
114
+ const metaUrl = `https://${domain}/.well-known/lnurlp/${name}`;
115
+ const metaRes = await fetch(metaUrl);
116
+ if (!metaRes.ok) throw new Error(`LNURL fetch failed (${metaRes.status}): ${metaUrl}`);
117
+ const meta = await metaRes.json();
118
+
119
+ if (meta.status === 'ERROR') throw new Error('LNURL error: ' + (meta.reason || 'unknown'));
120
+ if (!meta.callback) throw new Error('LNURL response missing callback URL');
121
+
122
+ const minSats = Math.ceil((meta.minSendable || 1000) / 1000);
123
+ const maxSats = Math.floor((meta.maxSendable || 100000000000) / 1000);
124
+
125
+ if (amountSats < minSats) throw new Error(`Amount ${amountSats} below minimum ${minSats} sats`);
126
+ if (amountSats > maxSats) throw new Error(`Amount ${amountSats} above maximum ${maxSats} sats`);
127
+
128
+ // Step 2: Request invoice from callback
129
+ const amountMsats = amountSats * 1000;
130
+ const sep = meta.callback.includes('?') ? '&' : '?';
131
+ let cbUrl = `${meta.callback}${sep}amount=${amountMsats}`;
132
+ if (comment && meta.commentAllowed && comment.length <= meta.commentAllowed) {
133
+ cbUrl += `&comment=${encodeURIComponent(comment)}`;
134
+ }
135
+
136
+ const invoiceRes = await fetch(cbUrl);
137
+ if (!invoiceRes.ok) throw new Error(`LNURL callback failed (${invoiceRes.status})`);
138
+ const invoiceData = await invoiceRes.json();
139
+
140
+ if (invoiceData.status === 'ERROR') throw new Error('LNURL error: ' + (invoiceData.reason || 'unknown'));
141
+ if (!invoiceData.pr) throw new Error('LNURL response missing invoice (pr field)');
142
+
143
+ return {
144
+ invoice: invoiceData.pr,
145
+ minSats,
146
+ maxSats
147
+ };
148
+ }
149
+
99
150
  // ─── NWC URL parser ───
100
151
 
101
152
  function parseNwcUrl(nwcUrl) {
@@ -295,6 +346,35 @@ class NWCWallet {
295
346
  return { paid: false, preimage: null, settledAt: null };
296
347
  }
297
348
 
349
+ /**
350
+ * Pay a Lightning address (LNURL-pay) like user@domain.com.
351
+ * Resolves the address to a bolt11 invoice, then pays it.
352
+ * @param {string} address - Lightning address (user@domain)
353
+ * @param {object} opts
354
+ * @param {number} opts.amountSats - Amount in satoshis (required)
355
+ * @param {string} [opts.comment] - Optional payer comment
356
+ * @param {number} [opts.timeoutMs] - Payment timeout
357
+ * @returns {Promise<{ preimage, paymentHash, invoice, amountSats }>}
358
+ */
359
+ async payAddress(address, opts = {}) {
360
+ if (!address || !address.includes('@')) {
361
+ throw new Error('Invalid Lightning address: must be user@domain');
362
+ }
363
+ if (!opts.amountSats || opts.amountSats <= 0) {
364
+ throw new Error('amountSats is required and must be positive');
365
+ }
366
+
367
+ const resolved = await resolveLightningAddress(address, opts.amountSats, opts.comment);
368
+ const payResult = await this.payInvoice(resolved.invoice, { timeoutMs: opts.timeoutMs || 30000 });
369
+
370
+ return {
371
+ preimage: payResult.preimage,
372
+ paymentHash: payResult.paymentHash,
373
+ invoice: resolved.invoice,
374
+ amountSats: opts.amountSats
375
+ };
376
+ }
377
+
298
378
  /**
299
379
  * Decode a bolt11 invoice (offline, no NWC needed).
300
380
  * @param {string} invoice - Bolt11 invoice string
@@ -327,5 +407,6 @@ module.exports = {
327
407
  createWallet,
328
408
  parseNwcUrl,
329
409
  decodeBolt11,
410
+ resolveLightningAddress,
330
411
  NWCWallet
331
412
  };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "lightning-agent",
3
- "version": "0.1.0",
4
- "description": "Lightning payments for AI agents. Two functions: charge and pay.",
3
+ "version": "0.3.0",
4
+ "description": "Lightning toolkit for AI agents. Payments, auth, escrow, and streaming micropayments.",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
7
7
  "lightning-agent": "bin/lightning-agent.js"
8
8
  },
9
- "keywords": ["lightning", "bitcoin", "ai", "agent", "nostr", "nwc", "payments"],
9
+ "keywords": ["lightning", "bitcoin", "ai", "agent", "nostr", "nwc", "payments", "escrow", "streaming", "lnurl", "auth", "micropayments"],
10
10
  "author": "Jeletor",
11
11
  "license": "MIT",
12
12
  "dependencies": {