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/README.md +201 -87
- package/bin/lightning-agent.js +19 -0
- package/lib/auth.js +380 -0
- package/lib/escrow.js +332 -0
- package/lib/index.js +20 -2
- package/lib/stream.js +477 -0
- package/lib/wallet.js +81 -0
- package/package.json +3 -3
- package/test-v030.js +282 -0
- package/test.js +37 -9
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
|
-
|
|
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.
|
|
4
|
-
"description": "Lightning
|
|
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": {
|