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/auth.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LNURL-auth for AI agents.
|
|
5
|
+
*
|
|
6
|
+
* Standard LNURL-auth (LUD-04) adapted for programmatic use.
|
|
7
|
+
* Agents sign challenges with their keys (Nostr or derived).
|
|
8
|
+
*
|
|
9
|
+
* Server: createAuthServer() — manages challenges, verifies signatures
|
|
10
|
+
* Client: signAuth() — signs a challenge programmatically
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
// ─── Server-side: challenge management ───
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create an LNURL-auth server/verifier.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [opts]
|
|
21
|
+
* @param {number} [opts.challengeTtlMs=300000] - Challenge validity (default 5 min)
|
|
22
|
+
* @param {string} [opts.callbackUrl] - Full callback URL for LNURL generation
|
|
23
|
+
* @returns {AuthServer}
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const auth = createAuthServer({ callbackUrl: 'https://api.example.com/auth' });
|
|
27
|
+
* const { k1, lnurl } = auth.createChallenge();
|
|
28
|
+
* // ... client signs k1 ...
|
|
29
|
+
* const ok = auth.verify(k1, sig, key);
|
|
30
|
+
*/
|
|
31
|
+
function createAuthServer(opts = {}) {
|
|
32
|
+
const ttl = opts.challengeTtlMs || 5 * 60 * 1000;
|
|
33
|
+
const callbackUrl = opts.callbackUrl || null;
|
|
34
|
+
const challenges = new Map(); // k1 → { createdAt, used }
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
/**
|
|
38
|
+
* Generate a new auth challenge.
|
|
39
|
+
* @returns {{ k1: string, lnurl?: string, expiresAt: number }}
|
|
40
|
+
*/
|
|
41
|
+
createChallenge() {
|
|
42
|
+
// Purge expired
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
for (const [k, v] of challenges) {
|
|
45
|
+
if (now - v.createdAt > ttl) challenges.delete(k);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const k1 = crypto.randomBytes(32).toString('hex');
|
|
49
|
+
challenges.set(k1, { createdAt: now, used: false });
|
|
50
|
+
|
|
51
|
+
const result = {
|
|
52
|
+
k1,
|
|
53
|
+
expiresAt: now + ttl
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Generate LNURL if callback URL provided
|
|
57
|
+
if (callbackUrl) {
|
|
58
|
+
const sep = callbackUrl.includes('?') ? '&' : '?';
|
|
59
|
+
const fullUrl = `${callbackUrl}${sep}tag=login&k1=${k1}&action=login`;
|
|
60
|
+
result.lnurl = bech32Encode('lnurl', fullUrl);
|
|
61
|
+
result.callbackUrl = fullUrl;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Verify a signed challenge.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} k1 - The challenge hex string
|
|
71
|
+
* @param {string} sig - DER-encoded signature (hex)
|
|
72
|
+
* @param {string} key - Signing public key (hex, 33 bytes compressed)
|
|
73
|
+
* @returns {{ valid: boolean, pubkey?: string, error?: string }}
|
|
74
|
+
*/
|
|
75
|
+
verify(k1, sig, key) {
|
|
76
|
+
if (!k1 || !sig || !key) {
|
|
77
|
+
return { valid: false, error: 'Missing k1, sig, or key' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const challenge = challenges.get(k1);
|
|
81
|
+
if (!challenge) {
|
|
82
|
+
return { valid: false, error: 'Unknown or expired challenge' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (challenge.used) {
|
|
86
|
+
return { valid: false, error: 'Challenge already used' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Date.now() - challenge.createdAt > ttl) {
|
|
90
|
+
challenges.delete(k1);
|
|
91
|
+
return { valid: false, error: 'Challenge expired' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const valid = verifySignature(k1, sig, key);
|
|
96
|
+
if (valid) {
|
|
97
|
+
challenge.used = true;
|
|
98
|
+
return { valid: true, pubkey: key };
|
|
99
|
+
}
|
|
100
|
+
return { valid: false, error: 'Invalid signature' };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { valid: false, error: err.message };
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Express/Connect middleware for LNURL-auth.
|
|
108
|
+
* Mount at your callback path.
|
|
109
|
+
*
|
|
110
|
+
* @param {function} onAuth - Called with (pubkey, req, res) on success
|
|
111
|
+
* @returns {function} HTTP request handler
|
|
112
|
+
*/
|
|
113
|
+
middleware(onAuth) {
|
|
114
|
+
return (req, res) => {
|
|
115
|
+
const url = new URL(req.url, 'http://localhost');
|
|
116
|
+
const k1 = url.searchParams.get('k1');
|
|
117
|
+
const sig = url.searchParams.get('sig');
|
|
118
|
+
const key = url.searchParams.get('key');
|
|
119
|
+
const tag = url.searchParams.get('tag');
|
|
120
|
+
|
|
121
|
+
// Initial request — return challenge
|
|
122
|
+
if (!sig && !key) {
|
|
123
|
+
const challenge = this.createChallenge();
|
|
124
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
125
|
+
res.end(JSON.stringify({ tag: 'login', k1: challenge.k1, action: 'login' }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Verification request
|
|
130
|
+
const result = this.verify(k1, sig, key);
|
|
131
|
+
if (result.valid) {
|
|
132
|
+
if (onAuth) onAuth(result.pubkey, req, res);
|
|
133
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
134
|
+
res.end(JSON.stringify({ status: 'OK' }));
|
|
135
|
+
} else {
|
|
136
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
137
|
+
res.end(JSON.stringify({ status: 'ERROR', reason: result.error }));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/** Number of active (unexpired, unused) challenges */
|
|
143
|
+
get activeChallenges() {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
let count = 0;
|
|
146
|
+
for (const [, v] of challenges) {
|
|
147
|
+
if (!v.used && now - v.createdAt <= ttl) count++;
|
|
148
|
+
}
|
|
149
|
+
return count;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Client-side: sign challenges programmatically ───
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sign an LNURL-auth challenge with a private key.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} k1 - Challenge hex (32 bytes)
|
|
160
|
+
* @param {string|Uint8Array} privateKey - Signing private key (hex or bytes)
|
|
161
|
+
* @returns {{ sig: string, key: string }}
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* const { sig, key } = signAuth(k1, mySecretKeyHex);
|
|
165
|
+
* // Send sig + key back to the auth server
|
|
166
|
+
*/
|
|
167
|
+
function signAuth(k1, privateKey) {
|
|
168
|
+
const secp = getSecp256k1();
|
|
169
|
+
const privBytes = typeof privateKey === 'string'
|
|
170
|
+
? Uint8Array.from(Buffer.from(privateKey, 'hex'))
|
|
171
|
+
: privateKey;
|
|
172
|
+
const msgBytes = Uint8Array.from(Buffer.from(k1, 'hex'));
|
|
173
|
+
|
|
174
|
+
// secp256k1.sign returns 64-byte compact sig (r||s) in @noble/curves v2
|
|
175
|
+
const compactSig = secp.sign(msgBytes, privBytes);
|
|
176
|
+
const sigDer = compactToDER(compactSig);
|
|
177
|
+
const pubkey = Buffer.from(secp.getPublicKey(privBytes, true)).toString('hex');
|
|
178
|
+
|
|
179
|
+
return { sig: sigDer, key: pubkey };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Complete an LNURL-auth flow programmatically.
|
|
184
|
+
* Fetches the challenge URL, signs it, sends the response.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} lnurlOrUrl - LNURL bech32 string or direct callback URL
|
|
187
|
+
* @param {string|Uint8Array} privateKey - Signing private key
|
|
188
|
+
* @returns {Promise<{ success: boolean, pubkey: string, error?: string }>}
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* const result = await authenticate('lnurl1...', mySecretKeyHex);
|
|
192
|
+
* if (result.success) console.log('Authenticated as', result.pubkey);
|
|
193
|
+
*/
|
|
194
|
+
async function authenticate(lnurlOrUrl, privateKey) {
|
|
195
|
+
// Decode LNURL if needed
|
|
196
|
+
let url = lnurlOrUrl;
|
|
197
|
+
if (lnurlOrUrl.toLowerCase().startsWith('lnurl')) {
|
|
198
|
+
url = bech32Decode(lnurlOrUrl);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Parse URL to extract k1
|
|
202
|
+
const parsed = new URL(url);
|
|
203
|
+
const k1 = parsed.searchParams.get('k1');
|
|
204
|
+
if (!k1) throw new Error('No k1 challenge in URL');
|
|
205
|
+
|
|
206
|
+
// Sign the challenge
|
|
207
|
+
const { sig, key } = signAuth(k1, privateKey);
|
|
208
|
+
|
|
209
|
+
// Send the signed response back
|
|
210
|
+
parsed.searchParams.set('sig', sig);
|
|
211
|
+
parsed.searchParams.set('key', key);
|
|
212
|
+
|
|
213
|
+
const res = await fetch(parsed.toString());
|
|
214
|
+
const data = await res.json();
|
|
215
|
+
|
|
216
|
+
if (data.status === 'OK') {
|
|
217
|
+
return { success: true, pubkey: key };
|
|
218
|
+
} else {
|
|
219
|
+
return { success: false, pubkey: key, error: data.reason || 'Auth failed' };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Helpers ───
|
|
224
|
+
|
|
225
|
+
// Get secp256k1 from nostr-tools dependency chain
|
|
226
|
+
function getSecp256k1() {
|
|
227
|
+
// @noble/curves v2+ uses .js extension in exports
|
|
228
|
+
try { return require('@noble/curves/secp256k1.js').secp256k1; } catch {}
|
|
229
|
+
try { return require('@noble/curves/secp256k1').secp256k1; } catch {}
|
|
230
|
+
try { return require('@noble/secp256k1'); } catch {}
|
|
231
|
+
throw new Error('secp256k1 not available — install @noble/curves or nostr-tools');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Verify a secp256k1 DER signature over a message hash
|
|
235
|
+
function verifySignature(k1Hex, sigHex, pubkeyHex) {
|
|
236
|
+
const secp = getSecp256k1();
|
|
237
|
+
const msg = Uint8Array.from(Buffer.from(k1Hex, 'hex'));
|
|
238
|
+
const compactSig = derToCompact(sigHex);
|
|
239
|
+
const pub = Uint8Array.from(Buffer.from(pubkeyHex, 'hex'));
|
|
240
|
+
return secp.verify(compactSig, msg, pub);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Convert 64-byte compact signature (r||s) to DER hex
|
|
244
|
+
function compactToDER(compact) {
|
|
245
|
+
const r = Buffer.from(compact.slice(0, 32));
|
|
246
|
+
const s = Buffer.from(compact.slice(32, 64));
|
|
247
|
+
|
|
248
|
+
function encodeInt(buf) {
|
|
249
|
+
let i = 0;
|
|
250
|
+
while (i < buf.length - 1 && buf[i] === 0) i++;
|
|
251
|
+
buf = buf.slice(i);
|
|
252
|
+
if (buf[0] & 0x80) buf = Buffer.concat([Buffer.from([0]), buf]);
|
|
253
|
+
return buf;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const rEnc = encodeInt(r);
|
|
257
|
+
const sEnc = encodeInt(s);
|
|
258
|
+
const body = Buffer.concat([
|
|
259
|
+
Buffer.from([0x02, rEnc.length]), rEnc,
|
|
260
|
+
Buffer.from([0x02, sEnc.length]), sEnc
|
|
261
|
+
]);
|
|
262
|
+
return Buffer.concat([Buffer.from([0x30, body.length]), body]).toString('hex');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Convert DER hex signature to 64-byte compact (r||s)
|
|
266
|
+
function derToCompact(derHex) {
|
|
267
|
+
const buf = Buffer.from(derHex, 'hex');
|
|
268
|
+
if (buf[0] !== 0x30) throw new Error('Invalid DER: missing sequence tag');
|
|
269
|
+
|
|
270
|
+
let offset = 2; // skip 0x30 + length byte
|
|
271
|
+
if (buf[1] > 127) offset++; // handle extended length (rare)
|
|
272
|
+
|
|
273
|
+
if (buf[offset] !== 0x02) throw new Error('Invalid DER: missing integer tag for r');
|
|
274
|
+
const rLen = buf[offset + 1];
|
|
275
|
+
offset += 2;
|
|
276
|
+
let r = buf.slice(offset, offset + rLen);
|
|
277
|
+
offset += rLen;
|
|
278
|
+
|
|
279
|
+
if (buf[offset] !== 0x02) throw new Error('Invalid DER: missing integer tag for s');
|
|
280
|
+
const sLen = buf[offset + 1];
|
|
281
|
+
offset += 2;
|
|
282
|
+
let s = buf.slice(offset, offset + sLen);
|
|
283
|
+
|
|
284
|
+
// Strip leading zeros and pad to 32 bytes
|
|
285
|
+
if (r[0] === 0 && r.length > 32) r = r.slice(1);
|
|
286
|
+
if (s[0] === 0 && s.length > 32) s = s.slice(1);
|
|
287
|
+
|
|
288
|
+
const rPad = Buffer.alloc(32); r.copy(rPad, 32 - r.length);
|
|
289
|
+
const sPad = Buffer.alloc(32); s.copy(sPad, 32 - s.length);
|
|
290
|
+
|
|
291
|
+
return Uint8Array.from(Buffer.concat([rPad, sPad]));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Minimal bech32 encode (for LNURL generation)
|
|
295
|
+
const BECH32_ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
296
|
+
|
|
297
|
+
function bech32Encode(prefix, data) {
|
|
298
|
+
const bytes = Buffer.from(data, 'utf8');
|
|
299
|
+
const words = toWords(bytes);
|
|
300
|
+
const checksum = bech32Checksum(prefix, words);
|
|
301
|
+
return prefix + '1' + [...words, ...checksum].map(w => BECH32_ALPHABET[w]).join('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function bech32Decode(str) {
|
|
305
|
+
const lower = str.toLowerCase();
|
|
306
|
+
const sepIdx = lower.lastIndexOf('1');
|
|
307
|
+
const data = lower.slice(sepIdx + 1);
|
|
308
|
+
const words = [];
|
|
309
|
+
for (let i = 0; i < data.length - 6; i++) {
|
|
310
|
+
words.push(BECH32_ALPHABET.indexOf(data[i]));
|
|
311
|
+
}
|
|
312
|
+
const bytes = fromWords(words);
|
|
313
|
+
return Buffer.from(bytes).toString('utf8');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function toWords(bytes) {
|
|
317
|
+
const words = [];
|
|
318
|
+
let acc = 0, bits = 0;
|
|
319
|
+
for (const b of bytes) {
|
|
320
|
+
acc = (acc << 8) | b;
|
|
321
|
+
bits += 8;
|
|
322
|
+
while (bits >= 5) {
|
|
323
|
+
bits -= 5;
|
|
324
|
+
words.push((acc >> bits) & 31);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (bits > 0) words.push((acc << (5 - bits)) & 31);
|
|
328
|
+
return words;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function fromWords(words) {
|
|
332
|
+
const bytes = [];
|
|
333
|
+
let acc = 0, bits = 0;
|
|
334
|
+
for (const w of words) {
|
|
335
|
+
acc = (acc << 5) | w;
|
|
336
|
+
bits += 5;
|
|
337
|
+
while (bits >= 8) {
|
|
338
|
+
bits -= 8;
|
|
339
|
+
bytes.push((acc >> bits) & 255);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return bytes;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function bech32Polymod(values) {
|
|
346
|
+
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
347
|
+
let chk = 1;
|
|
348
|
+
for (const v of values) {
|
|
349
|
+
const b = chk >> 25;
|
|
350
|
+
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
|
351
|
+
for (let i = 0; i < 5; i++) {
|
|
352
|
+
if ((b >> i) & 1) chk ^= GEN[i];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return chk;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function bech32Checksum(prefix, words) {
|
|
359
|
+
const prefixExpanded = [];
|
|
360
|
+
for (const c of prefix) {
|
|
361
|
+
prefixExpanded.push(c.charCodeAt(0) >> 5);
|
|
362
|
+
}
|
|
363
|
+
prefixExpanded.push(0);
|
|
364
|
+
for (const c of prefix) {
|
|
365
|
+
prefixExpanded.push(c.charCodeAt(0) & 31);
|
|
366
|
+
}
|
|
367
|
+
const values = [...prefixExpanded, ...words, 0, 0, 0, 0, 0, 0];
|
|
368
|
+
const poly = bech32Polymod(values) ^ 1;
|
|
369
|
+
const checksum = [];
|
|
370
|
+
for (let i = 0; i < 6; i++) {
|
|
371
|
+
checksum.push((poly >> (5 * (5 - i))) & 31);
|
|
372
|
+
}
|
|
373
|
+
return checksum;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = {
|
|
377
|
+
createAuthServer,
|
|
378
|
+
signAuth,
|
|
379
|
+
authenticate
|
|
380
|
+
};
|
package/lib/escrow.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightning escrow for agent-to-agent work.
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
* 1. Client and worker agree on terms (amount, deadline, verifier)
|
|
8
|
+
* 2. Client funds the escrow (pays invoice to escrow wallet)
|
|
9
|
+
* 3. Worker delivers work + proof
|
|
10
|
+
* 4. Escrow releases payment to worker (or refunds on timeout/dispute)
|
|
11
|
+
*
|
|
12
|
+
* The escrow wallet is custodial — it holds funds between funding and release.
|
|
13
|
+
* This is the practical tradeoff: real escrow without hold invoices.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const mgr = createEscrowManager(escrowWallet);
|
|
17
|
+
* const escrow = await mgr.create({
|
|
18
|
+
* amountSats: 500,
|
|
19
|
+
* workerAddress: 'worker@getalby.com',
|
|
20
|
+
* description: 'Translate 200 words EN→ES',
|
|
21
|
+
* deadlineMs: 3600000 // 1 hour
|
|
22
|
+
* });
|
|
23
|
+
* // Client pays: escrow.invoice
|
|
24
|
+
* await mgr.fund(escrow.id);
|
|
25
|
+
* // Worker delivers...
|
|
26
|
+
* await mgr.release(escrow.id);
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
|
|
31
|
+
// Escrow states
|
|
32
|
+
const State = {
|
|
33
|
+
CREATED: 'created', // Terms defined, invoice generated, awaiting payment
|
|
34
|
+
FUNDED: 'funded', // Client paid, worker can begin
|
|
35
|
+
DELIVERED: 'delivered', // Worker submitted proof, awaiting verification
|
|
36
|
+
RELEASED: 'released', // Payment sent to worker — terminal
|
|
37
|
+
REFUNDED: 'refunded', // Payment returned to client — terminal
|
|
38
|
+
EXPIRED: 'expired', // Deadline passed without delivery — terminal
|
|
39
|
+
DISPUTED: 'disputed' // Dispute raised, manual resolution needed
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create an escrow manager backed by an NWC wallet.
|
|
44
|
+
*
|
|
45
|
+
* @param {NWCWallet} wallet - Wallet that holds escrowed funds
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
* @param {function} [opts.onStateChange] - Called with (escrowId, oldState, newState, escrow)
|
|
48
|
+
* @param {number} [opts.defaultDeadlineMs=3600000] - Default deadline (1 hour)
|
|
49
|
+
* @returns {EscrowManager}
|
|
50
|
+
*/
|
|
51
|
+
function createEscrowManager(wallet, opts = {}) {
|
|
52
|
+
if (!wallet) throw new Error('Escrow wallet is required');
|
|
53
|
+
|
|
54
|
+
const defaultDeadlineMs = opts.defaultDeadlineMs || 60 * 60 * 1000;
|
|
55
|
+
const onStateChange = opts.onStateChange || null;
|
|
56
|
+
const escrows = new Map();
|
|
57
|
+
const timers = new Map();
|
|
58
|
+
|
|
59
|
+
function transition(id, newState) {
|
|
60
|
+
const e = escrows.get(id);
|
|
61
|
+
if (!e) throw new Error('Unknown escrow: ' + id);
|
|
62
|
+
const old = e.state;
|
|
63
|
+
e.state = newState;
|
|
64
|
+
e.updatedAt = Date.now();
|
|
65
|
+
e.history.push({ from: old, to: newState, at: e.updatedAt });
|
|
66
|
+
if (onStateChange) onStateChange(id, old, newState, e);
|
|
67
|
+
return e;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startDeadlineTimer(id) {
|
|
71
|
+
const e = escrows.get(id);
|
|
72
|
+
if (!e || !e.deadline) return;
|
|
73
|
+
const remaining = e.deadline - Date.now();
|
|
74
|
+
if (remaining <= 0) {
|
|
75
|
+
if (e.state === State.FUNDED || e.state === State.CREATED) {
|
|
76
|
+
transition(id, State.EXPIRED);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
const current = escrows.get(id);
|
|
82
|
+
if (current && (current.state === State.FUNDED || current.state === State.CREATED)) {
|
|
83
|
+
transition(id, State.EXPIRED);
|
|
84
|
+
}
|
|
85
|
+
timers.delete(id);
|
|
86
|
+
}, remaining);
|
|
87
|
+
timer.unref(); // Don't keep process alive
|
|
88
|
+
timers.set(id, timer);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
State,
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a new escrow.
|
|
96
|
+
* Generates a Lightning invoice for the client to pay.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} config
|
|
99
|
+
* @param {number} config.amountSats - Escrow amount
|
|
100
|
+
* @param {string} config.workerAddress - Worker's Lightning address (user@domain)
|
|
101
|
+
* @param {string} [config.workerInvoice] - Or a specific bolt11 invoice to pay on release
|
|
102
|
+
* @param {string} [config.description] - Work description
|
|
103
|
+
* @param {number} [config.deadlineMs] - Time until auto-expire (from creation)
|
|
104
|
+
* @param {string} [config.clientPubkey] - Client identifier
|
|
105
|
+
* @param {string} [config.workerPubkey] - Worker identifier
|
|
106
|
+
* @param {object} [config.metadata] - Arbitrary metadata
|
|
107
|
+
* @returns {Promise<Escrow>}
|
|
108
|
+
*/
|
|
109
|
+
async create(config) {
|
|
110
|
+
if (!config.amountSats || config.amountSats <= 0) {
|
|
111
|
+
throw new Error('amountSats must be positive');
|
|
112
|
+
}
|
|
113
|
+
if (!config.workerAddress && !config.workerInvoice) {
|
|
114
|
+
throw new Error('workerAddress or workerInvoice required');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const deadlineMs = config.deadlineMs || defaultDeadlineMs;
|
|
120
|
+
|
|
121
|
+
// Create invoice for client to pay
|
|
122
|
+
const inv = await wallet.createInvoice({
|
|
123
|
+
amountSats: config.amountSats,
|
|
124
|
+
description: `Escrow: ${config.description || id}`,
|
|
125
|
+
expiry: Math.ceil(deadlineMs / 1000)
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const escrow = {
|
|
129
|
+
id,
|
|
130
|
+
state: State.CREATED,
|
|
131
|
+
amountSats: config.amountSats,
|
|
132
|
+
description: config.description || null,
|
|
133
|
+
workerAddress: config.workerAddress || null,
|
|
134
|
+
workerInvoice: config.workerInvoice || null,
|
|
135
|
+
clientPubkey: config.clientPubkey || null,
|
|
136
|
+
workerPubkey: config.workerPubkey || null,
|
|
137
|
+
metadata: config.metadata || {},
|
|
138
|
+
invoice: inv.invoice,
|
|
139
|
+
paymentHash: inv.paymentHash,
|
|
140
|
+
deadline: now + deadlineMs,
|
|
141
|
+
createdAt: now,
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
fundedAt: null,
|
|
144
|
+
deliveredAt: null,
|
|
145
|
+
releasedAt: null,
|
|
146
|
+
refundedAt: null,
|
|
147
|
+
deliveryProof: null,
|
|
148
|
+
releasePreimage: null,
|
|
149
|
+
refundAddress: null,
|
|
150
|
+
history: [{ from: null, to: State.CREATED, at: now }]
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
escrows.set(id, escrow);
|
|
154
|
+
startDeadlineTimer(id);
|
|
155
|
+
|
|
156
|
+
return { ...escrow };
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Mark escrow as funded (client payment received).
|
|
161
|
+
* Call this after confirming the client's payment landed.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} id - Escrow ID
|
|
164
|
+
* @param {object} [opts]
|
|
165
|
+
* @param {boolean} [opts.autoDetect=true] - Poll wallet for payment confirmation
|
|
166
|
+
* @param {number} [opts.timeoutMs=60000] - Payment detection timeout
|
|
167
|
+
* @returns {Promise<Escrow>}
|
|
168
|
+
*/
|
|
169
|
+
async fund(id, opts = {}) {
|
|
170
|
+
const e = escrows.get(id);
|
|
171
|
+
if (!e) throw new Error('Unknown escrow: ' + id);
|
|
172
|
+
if (e.state !== State.CREATED) {
|
|
173
|
+
throw new Error(`Cannot fund escrow in state: ${e.state}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const autoDetect = opts.autoDetect !== false;
|
|
177
|
+
|
|
178
|
+
if (autoDetect && e.paymentHash) {
|
|
179
|
+
// Wait for payment confirmation
|
|
180
|
+
const result = await wallet.waitForPayment(e.paymentHash, {
|
|
181
|
+
timeoutMs: opts.timeoutMs || 60000
|
|
182
|
+
});
|
|
183
|
+
if (!result.paid) {
|
|
184
|
+
throw new Error('Payment not received within timeout');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
e.fundedAt = Date.now();
|
|
189
|
+
return { ...transition(id, State.FUNDED) };
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Worker submits delivery proof.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} id - Escrow ID
|
|
196
|
+
* @param {string|object} proof - Delivery proof (hash, URL, description, etc.)
|
|
197
|
+
* @returns {Escrow}
|
|
198
|
+
*/
|
|
199
|
+
deliver(id, proof) {
|
|
200
|
+
const e = escrows.get(id);
|
|
201
|
+
if (!e) throw new Error('Unknown escrow: ' + id);
|
|
202
|
+
if (e.state !== State.FUNDED) {
|
|
203
|
+
throw new Error(`Cannot deliver on escrow in state: ${e.state}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
e.deliveryProof = proof;
|
|
207
|
+
e.deliveredAt = Date.now();
|
|
208
|
+
return { ...transition(id, State.DELIVERED) };
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Release escrowed funds to the worker.
|
|
213
|
+
* Pays the worker's Lightning address or invoice.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} id - Escrow ID
|
|
216
|
+
* @returns {Promise<Escrow>}
|
|
217
|
+
*/
|
|
218
|
+
async release(id) {
|
|
219
|
+
const e = escrows.get(id);
|
|
220
|
+
if (!e) throw new Error('Unknown escrow: ' + id);
|
|
221
|
+
if (e.state !== State.FUNDED && e.state !== State.DELIVERED) {
|
|
222
|
+
throw new Error(`Cannot release escrow in state: ${e.state}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let payResult;
|
|
226
|
+
if (e.workerInvoice) {
|
|
227
|
+
payResult = await wallet.payInvoice(e.workerInvoice);
|
|
228
|
+
} else if (e.workerAddress) {
|
|
229
|
+
payResult = await wallet.payAddress(e.workerAddress, {
|
|
230
|
+
amountSats: e.amountSats,
|
|
231
|
+
comment: `Escrow release: ${e.description || e.id}`
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
throw new Error('No worker payment destination');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
e.releasePreimage = payResult.preimage;
|
|
238
|
+
e.releasedAt = Date.now();
|
|
239
|
+
|
|
240
|
+
// Cancel deadline timer
|
|
241
|
+
const timer = timers.get(id);
|
|
242
|
+
if (timer) { clearTimeout(timer); timers.delete(id); }
|
|
243
|
+
|
|
244
|
+
return { ...transition(id, State.RELEASED) };
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Refund escrowed funds to the client.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} id - Escrow ID
|
|
251
|
+
* @param {string} refundAddress - Client's Lightning address for refund
|
|
252
|
+
* @param {string} [reason] - Refund reason
|
|
253
|
+
* @returns {Promise<Escrow>}
|
|
254
|
+
*/
|
|
255
|
+
async refund(id, refundAddress, reason) {
|
|
256
|
+
const e = escrows.get(id);
|
|
257
|
+
if (!e) throw new Error('Unknown escrow: ' + id);
|
|
258
|
+
if (e.state === State.RELEASED) {
|
|
259
|
+
throw new Error('Cannot refund — already released');
|
|
260
|
+
}
|
|
261
|
+
if (e.state === State.REFUNDED) {
|
|
262
|
+
throw new Error('Already refunded');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (refundAddress) {
|
|
266
|
+
await wallet.payAddress(refundAddress, {
|
|
267
|
+
amountSats: e.amountSats,
|
|
268
|
+
comment: `Escrow refund: ${reason || e.id}`
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
e.refundAddress = refundAddress;
|
|
273
|
+
e.refundedAt = Date.now();
|
|
274
|
+
e.metadata.refundReason = reason || 'manual';
|
|
275
|
+
|
|
276
|
+
const timer = timers.get(id);
|
|
277
|
+
if (timer) { clearTimeout(timer); timers.delete(id); }
|
|
278
|
+
|
|
279
|
+
return { ...transition(id, State.REFUNDED) };
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Raise a dispute on an escrow.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} id - Escrow ID
|
|
286
|
+
* @param {string} reason - Dispute reason
|
|
287
|
+
* @param {string} raisedBy - 'client' or 'worker'
|
|
288
|
+
* @returns {Escrow}
|
|
289
|
+
*/
|
|
290
|
+
dispute(id, reason, raisedBy) {
|
|
291
|
+
const e = escrows.get(id);
|
|
292
|
+
if (!e) throw new Error('Unknown escrow: ' + id);
|
|
293
|
+
if (e.state === State.RELEASED || e.state === State.REFUNDED) {
|
|
294
|
+
throw new Error(`Cannot dispute — escrow already ${e.state}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
e.metadata.dispute = { reason, raisedBy, at: Date.now() };
|
|
298
|
+
return { ...transition(id, State.DISPUTED) };
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get escrow status.
|
|
303
|
+
* @param {string} id
|
|
304
|
+
* @returns {Escrow|null}
|
|
305
|
+
*/
|
|
306
|
+
get(id) {
|
|
307
|
+
const e = escrows.get(id);
|
|
308
|
+
return e ? { ...e } : null;
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* List all escrows, optionally filtered by state.
|
|
313
|
+
* @param {string} [state] - Filter by state
|
|
314
|
+
* @returns {Escrow[]}
|
|
315
|
+
*/
|
|
316
|
+
list(state) {
|
|
317
|
+
const all = [...escrows.values()];
|
|
318
|
+
if (state) return all.filter(e => e.state === state).map(e => ({ ...e }));
|
|
319
|
+
return all.map(e => ({ ...e }));
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Cleanup — cancel all timers.
|
|
324
|
+
*/
|
|
325
|
+
close() {
|
|
326
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
327
|
+
timers.clear();
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = { createEscrowManager, State };
|