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/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 };