@johngalt5/bsv-overlay 0.2.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.
@@ -0,0 +1,4536 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * overlay-cli.mjs — Unified CLI for the Clawdbot BSV Overlay skill.
4
+ *
5
+ * Combines BSV wallet management, overlay registration/discovery, service
6
+ * advertisement, and micropayments into a single self-contained CLI.
7
+ *
8
+ * All output is JSON with a { success, data/error } wrapper for agent parsing.
9
+ *
10
+ * Environment variables:
11
+ * BSV_WALLET_DIR — wallet storage directory (default: ~/.clawdbot/bsv-wallet)
12
+ * BSV_NETWORK — 'mainnet' or 'testnet' (default: mainnet)
13
+ * OVERLAY_URL — overlay server URL (default: http://162.243.168.235:8080)
14
+ * AGENT_NAME — agent display name for registration
15
+ *
16
+ * Commands:
17
+ * setup — Create wallet, show identity
18
+ * identity — Show identity public key
19
+ * address — Show P2PKH receive address
20
+ * balance — Show balance in satoshis
21
+ * import <txid> [vout] — Import external UTXO with merkle proof
22
+ * refund <address> — Sweep wallet to address
23
+ * register — Register identity + joke service on overlay
24
+ * unregister — (future) Remove from overlay
25
+ * services — List my advertised services
26
+ * advertise <serviceId> <name> <desc> <priceSats> — Add a service to overlay
27
+ * readvertise <serviceId> <newPrice> [name] [desc] — Re-advertise service with new price
28
+ * remove <serviceId> — Remove a service (future)
29
+ * discover [--service <type>] [--agent <name>] — Find agents/services on overlay
30
+ * pay <identityKey> <sats> [description] — Pay another agent
31
+ * verify <beef_base64> — Verify incoming payment
32
+ * accept <beef> <prefix> <suffix> <senderKey> [desc] — Accept payment
33
+ *
34
+ * Messaging:
35
+ * send <identityKey> <type> <json_payload> — Send a message via relay
36
+ * inbox [--since <ms>] — Check inbox for pending messages
37
+ * ack <messageId> [messageId2 ...] — Mark messages as read
38
+ * poll — Process inbox (auto-handle known types)
39
+ * connect — WebSocket real-time message processing
40
+ * request-service <identityKey> <serviceId> [sats] — Pay + request a service
41
+ */
42
+
43
+ // Suppress dotenv noise
44
+ const _origLog = console.log;
45
+ console.log = () => {};
46
+
47
+ import { fileURLToPath } from 'node:url';
48
+ import path from 'node:path';
49
+ import os from 'node:os';
50
+ import fs from 'node:fs';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Resolve the @a2a-bsv/core library
54
+ // ---------------------------------------------------------------------------
55
+ let core;
56
+ try {
57
+ core = await import('@a2a-bsv/core');
58
+ } catch {
59
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
60
+ // Try several possible paths
61
+ const candidates = [
62
+ path.resolve(__dirname, '..', 'node_modules', '@a2a-bsv', 'core', 'dist', 'index.js'),
63
+ path.resolve(__dirname, '..', '..', '..', 'a2a-bsv', 'packages', 'core', 'dist', 'index.js'),
64
+ path.resolve(os.homedir(), 'a2a-bsv', 'packages', 'core', 'dist', 'index.js'),
65
+ ];
66
+ for (const p of candidates) {
67
+ try {
68
+ core = await import(p);
69
+ break;
70
+ } catch { /* next */ }
71
+ }
72
+ if (!core) {
73
+ console.log = _origLog;
74
+ console.log(JSON.stringify({ success: false, error: 'Cannot find @a2a-bsv/core. Run setup.sh first.' }));
75
+ process.exit(1);
76
+ }
77
+ }
78
+ const { BSVAgentWallet } = core;
79
+
80
+ // Resolve @bsv/sdk
81
+ let sdk;
82
+ try {
83
+ sdk = await import('@bsv/sdk');
84
+ } catch {
85
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
86
+ const candidates = [
87
+ path.resolve(__dirname, '..', 'node_modules', '@bsv', 'sdk', 'dist', 'esm', 'mod.js'),
88
+ path.resolve(__dirname, '..', '..', '..', 'a2a-bsv', 'packages', 'core', 'node_modules', '@bsv', 'sdk', 'dist', 'esm', 'mod.js'),
89
+ path.resolve(os.homedir(), 'a2a-bsv', 'packages', 'core', 'node_modules', '@bsv', 'sdk', 'dist', 'esm', 'mod.js'),
90
+ ];
91
+ for (const p of candidates) {
92
+ try {
93
+ sdk = await import(p);
94
+ break;
95
+ } catch { /* next */ }
96
+ }
97
+ if (!sdk) {
98
+ console.log = _origLog;
99
+ console.log(JSON.stringify({ success: false, error: 'Cannot find @bsv/sdk. Run setup.sh first.' }));
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ // Restore console.log
105
+ console.log = _origLog;
106
+
107
+ const { PrivateKey, PublicKey, Hash, Utils, Transaction, Script, P2PKH, Beef, MerklePath, Signature } = sdk;
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Config
111
+ // ---------------------------------------------------------------------------
112
+
113
+ // Auto-load .env from overlay state dir if it exists
114
+ const _overlayEnvPath = path.join(os.homedir(), '.clawdbot', 'bsv-overlay', '.env');
115
+ try {
116
+ if (fs.existsSync(_overlayEnvPath)) {
117
+ for (const line of fs.readFileSync(_overlayEnvPath, 'utf-8').split('\n')) {
118
+ const match = line.match(/^([A-Z_]+)=(.+)$/);
119
+ if (match && !process.env[match[1]]) process.env[match[1]] = match[2].trim();
120
+ }
121
+ }
122
+ } catch {}
123
+
124
+ const WALLET_DIR = process.env.BSV_WALLET_DIR
125
+ || path.join(os.homedir(), '.clawdbot', 'bsv-wallet');
126
+ const NETWORK = process.env.BSV_NETWORK || 'mainnet';
127
+ const OVERLAY_URL = process.env.OVERLAY_URL || 'http://162.243.168.235:8080';
128
+ const WOC_API_KEY = process.env.WOC_API_KEY || '';
129
+ const OVERLAY_STATE_DIR = path.join(os.homedir(), '.clawdbot', 'bsv-overlay');
130
+ const PROTOCOL_ID = 'clawdbot-overlay-v1';
131
+
132
+ /**
133
+ * Fetch from WhatsonChain with optional API key auth and retry logic.
134
+ * Retries on 429 (rate limit) and 5xx errors with exponential backoff.
135
+ * Includes timeout to prevent hanging indefinitely.
136
+ */
137
+ async function wocFetch(urlPath, options = {}, maxRetries = 3, timeoutMs = 30000) {
138
+ const wocNet = NETWORK === 'mainnet' ? 'main' : 'test';
139
+ const base = `https://api.whatsonchain.com/v1/bsv/${wocNet}`;
140
+ const url = urlPath.startsWith('http') ? urlPath : `${base}${urlPath}`;
141
+ const headers = { ...(options.headers || {}) };
142
+ if (WOC_API_KEY) headers['Authorization'] = `Bearer ${WOC_API_KEY}`;
143
+
144
+ let lastError;
145
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
146
+ try {
147
+ // Add timeout via AbortController
148
+ const controller = new AbortController();
149
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
150
+
151
+ const resp = await fetch(url, { ...options, headers, signal: controller.signal });
152
+ clearTimeout(timeout);
153
+
154
+ // Retry on 429 (rate limit) or 5xx (server error)
155
+ if ((resp.status === 429 || resp.status >= 500) && attempt < maxRetries) {
156
+ const delayMs = Math.min(1000 * Math.pow(2, attempt), 8000); // 1s, 2s, 4s, 8s max
157
+ await new Promise(r => setTimeout(r, delayMs));
158
+ continue;
159
+ }
160
+
161
+ return resp;
162
+ } catch (err) {
163
+ lastError = err;
164
+ if (attempt < maxRetries) {
165
+ const delayMs = Math.min(1000 * Math.pow(2, attempt), 8000);
166
+ await new Promise(r => setTimeout(r, delayMs));
167
+ continue;
168
+ }
169
+ }
170
+ }
171
+
172
+ throw lastError || new Error('WoC fetch failed after retries');
173
+ }
174
+ const TOPICS = { IDENTITY: 'tm_clawdbot_identity', SERVICES: 'tm_clawdbot_services' };
175
+ const LOOKUP_SERVICES = { AGENTS: 'ls_clawdbot_agents', SERVICES: 'ls_clawdbot_services' };
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // JSON output helpers
179
+ // ---------------------------------------------------------------------------
180
+ function ok(data) {
181
+ console.log(JSON.stringify({ success: true, data }));
182
+ process.exit(0);
183
+ }
184
+ function fail(error) {
185
+ console.log(JSON.stringify({ success: false, error: String(error) }));
186
+ process.exit(1);
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Overlay helpers
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** Build an OP_FALSE OP_RETURN script with protocol prefix + JSON payload. */
194
+ function buildOpReturnScript(payload) {
195
+ const protocolBytes = Array.from(new TextEncoder().encode(PROTOCOL_ID));
196
+ const jsonBytes = Array.from(new TextEncoder().encode(JSON.stringify(payload)));
197
+ const script = new Script();
198
+ script.writeOpCode(0x00); // OP_FALSE
199
+ script.writeOpCode(0x6a); // OP_RETURN
200
+ script.writeBin(protocolBytes);
201
+ script.writeBin(jsonBytes);
202
+ return script;
203
+ }
204
+
205
+ /** Submit BEEF to the overlay. */
206
+ async function submitToOverlay(beefData, topics) {
207
+ const url = `${OVERLAY_URL}/submit`;
208
+ const response = await fetch(url, {
209
+ method: 'POST',
210
+ headers: {
211
+ 'Content-Type': 'application/octet-stream',
212
+ 'X-Topics': JSON.stringify(topics),
213
+ },
214
+ body: new Uint8Array(beefData),
215
+ });
216
+ if (!response.ok) {
217
+ const body = await response.text();
218
+ throw new Error(`Overlay submit failed (${response.status}): ${body}`);
219
+ }
220
+ return await response.json();
221
+ }
222
+
223
+ /** Query the overlay via lookup service. */
224
+ async function lookupOverlay(service, query = {}) {
225
+ const url = `${OVERLAY_URL}/lookup`;
226
+ const response = await fetch(url, {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify({ service, query }),
230
+ });
231
+ if (!response.ok) {
232
+ const body = await response.text();
233
+ throw new Error(`Overlay lookup failed (${response.status}): ${body}`);
234
+ }
235
+ return await response.json();
236
+ }
237
+
238
+ /** Parse a Clawdbot OP_RETURN output from BEEF data. */
239
+ function parseOverlayOutput(beef, outputIndex) {
240
+ try {
241
+ const tx = Transaction.fromBEEF(beef);
242
+ const output = tx.outputs[outputIndex];
243
+ if (!output?.lockingScript) return null;
244
+
245
+ const chunks = output.lockingScript.chunks;
246
+ let pushes = null;
247
+
248
+ // Legacy 4+ chunk format
249
+ if (chunks.length >= 4 && chunks[0].op === 0x00 && chunks[1].op === 0x6a) {
250
+ pushes = [];
251
+ for (let i = 2; i < chunks.length; i++) {
252
+ if (chunks[i].data) pushes.push(new Uint8Array(chunks[i].data));
253
+ }
254
+ }
255
+ // Collapsed 2-chunk format (SDK v1.10+)
256
+ else if (chunks.length === 2 && chunks[0].op === 0x00 && chunks[1].op === 0x6a && chunks[1].data) {
257
+ const blob = chunks[1].data;
258
+ pushes = [];
259
+ let pos = 0;
260
+ while (pos < blob.length) {
261
+ const op = blob[pos++];
262
+ if (op > 0 && op <= 75) {
263
+ const end = Math.min(pos + op, blob.length);
264
+ pushes.push(new Uint8Array(blob.slice(pos, end)));
265
+ pos = end;
266
+ } else if (op === 0x4c) {
267
+ const len = blob[pos++] ?? 0;
268
+ const end = Math.min(pos + len, blob.length);
269
+ pushes.push(new Uint8Array(blob.slice(pos, end)));
270
+ pos = end;
271
+ } else if (op === 0x4d) {
272
+ const len = (blob[pos] ?? 0) | ((blob[pos + 1] ?? 0) << 8);
273
+ pos += 2;
274
+ const end = Math.min(pos + len, blob.length);
275
+ pushes.push(new Uint8Array(blob.slice(pos, end)));
276
+ pos = end;
277
+ } else {
278
+ break;
279
+ }
280
+ }
281
+ if (pushes.length < 2) pushes = null;
282
+ }
283
+
284
+ if (!pushes || pushes.length < 2) return null;
285
+
286
+ const protocolStr = new TextDecoder().decode(pushes[0]);
287
+ if (protocolStr !== PROTOCOL_ID) return null;
288
+
289
+ return JSON.parse(new TextDecoder().decode(pushes[1]));
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Build an OP_RETURN overlay transaction using the real funded wallet.
297
+ *
298
+ * Strategy: manually construct the transaction using a real UTXO from the
299
+ * wallet's on-chain holdings, sign it, build BEEF with merkle proof, and
300
+ * import the change output back into the wallet.
301
+ *
302
+ * Falls back to synthetic funding (scripts-only mode) if wallet has no balance.
303
+ */
304
+ async function buildRealOverlayTransaction(payload, topic) {
305
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
306
+ if (!fs.existsSync(identityPath)) {
307
+ throw new Error('Wallet not initialized. Run: overlay-cli setup');
308
+ }
309
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
310
+ const privKey = PrivateKey.fromHex(identity.rootKeyHex);
311
+ const pubKey = privKey.toPublicKey();
312
+
313
+ // Derive the wallet's P2PKH address
314
+ const pubKeyBytes = pubKey.encode(true);
315
+ const hash160 = Hash.hash160(pubKeyBytes);
316
+ const prefix = NETWORK === 'mainnet' ? 0x00 : 0x6f;
317
+ const addrPayload = new Uint8Array([prefix, ...hash160]);
318
+ const checksum = Hash.hash256(Array.from(addrPayload)).slice(0, 4);
319
+ const addressBytes = new Uint8Array([...addrPayload, ...checksum]);
320
+ const walletAddress = Utils.toBase58(Array.from(addressBytes));
321
+
322
+ // === BEEF-first approach: use stored BEEF chain (no WoC, no blocks) ===
323
+ const beefStorePath = path.join(OVERLAY_STATE_DIR, 'latest-change.json');
324
+ let storedChange = null;
325
+ try {
326
+ if (fs.existsSync(beefStorePath)) {
327
+ storedChange = JSON.parse(fs.readFileSync(beefStorePath, 'utf-8'));
328
+ }
329
+ } catch {}
330
+
331
+ if (storedChange && storedChange.txHex && storedChange.satoshis >= 200) {
332
+ try {
333
+ return await buildFromStoredBeef(payload, topic, storedChange, privKey, pubKey, hash160);
334
+ } catch (storedErr) {
335
+ // Stored BEEF failed — fall through to WoC
336
+ console.error(`[buildTx] Stored BEEF failed: ${storedErr.message}`);
337
+ }
338
+ }
339
+
340
+ // === Fallback: WoC UTXO lookup (needs confirmed tx with proof) ===
341
+ const wocNet = NETWORK === 'mainnet' ? 'main' : 'test';
342
+ const wocBase = `https://api.whatsonchain.com/v1/bsv/${wocNet}`;
343
+ let utxos = [];
344
+ try {
345
+ const resp = await wocFetch(`/address/${walletAddress}/unspent`);
346
+ if (resp.ok) utxos = await resp.json();
347
+ } catch {}
348
+ utxos = utxos.filter(u => u.value >= 200);
349
+
350
+ if (utxos.length > 0) {
351
+ try {
352
+ return await buildRealFundedTx(payload, topic, utxos[0], privKey, pubKey, hash160, walletAddress, wocBase);
353
+ } catch (realErr) {
354
+ // Fall through
355
+ }
356
+ }
357
+
358
+ // === Last resort: wallet createAction or synthetic ===
359
+ try {
360
+ return await buildWalletCreateActionTx(payload, topic, identity);
361
+ } catch (walletErr) {
362
+ return buildSyntheticTx(payload, privKey, pubKey);
363
+ }
364
+ }
365
+
366
+ /** Save the change output's full tx chain for instant reuse (no WoC, no blocks needed) */
367
+ function saveChangeBeef(tx, changeSats, changeVout) {
368
+ const beefStorePath = path.join(OVERLAY_STATE_DIR, 'latest-change.json');
369
+ try {
370
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
371
+ fs.writeFileSync(beefStorePath, JSON.stringify({
372
+ txHex: tx.toHex(),
373
+ txid: tx.id('hex'),
374
+ vout: changeVout,
375
+ satoshis: changeSats,
376
+ // Store the full source chain as hex for reconstruction
377
+ sourceChain: serializeSourceChain(tx),
378
+ savedAt: new Date().toISOString(),
379
+ }));
380
+ } catch {}
381
+ }
382
+
383
+ /** Serialize the tx's input source chain (for BEEF reconstruction) */
384
+ function serializeSourceChain(tx) {
385
+ const chain = [];
386
+ let cur = tx;
387
+ for (let depth = 0; depth < 15; depth++) {
388
+ const src = cur.inputs?.[0]?.sourceTransaction;
389
+ if (!src) break;
390
+ const entry = { txHex: src.toHex(), txid: src.id('hex') };
391
+ if (src.merklePath) {
392
+ entry.merklePathHex = Array.from(src.merklePath.toBinary()).map(b => b.toString(16).padStart(2, '0')).join('');
393
+ entry.blockHeight = src.merklePath.blockHeight;
394
+ }
395
+ chain.push(entry);
396
+ cur = src;
397
+ }
398
+ return chain;
399
+ }
400
+
401
+ /** Reconstruct a tx with its full source chain from stored data */
402
+ function reconstructFromChain(storedChange) {
403
+ const tx = Transaction.fromHex(storedChange.txHex);
404
+
405
+ // Rebuild source chain
406
+ if (storedChange.sourceChain && storedChange.sourceChain.length > 0) {
407
+ let childTx = tx;
408
+ for (const entry of storedChange.sourceChain) {
409
+ const srcTx = Transaction.fromHex(entry.txHex);
410
+ if (entry.merklePathHex) {
411
+ const mpBytes = entry.merklePathHex.match(/.{2}/g).map(h => parseInt(h, 16));
412
+ srcTx.merklePath = MerklePath.fromBinary(mpBytes);
413
+ }
414
+ childTx.inputs[0].sourceTransaction = srcTx;
415
+ childTx = srcTx;
416
+ }
417
+ }
418
+ return tx;
419
+ }
420
+
421
+ /** Build a tx using a stored BEEF chain (instant, no WoC needed) */
422
+ async function buildFromStoredBeef(payload, topic, storedChange, privKey, pubKey, hash160) {
423
+ const sourceTx = reconstructFromChain(storedChange);
424
+
425
+ const opReturnScript = buildOpReturnScript(payload);
426
+ const tx = new Transaction();
427
+ tx.addInput({
428
+ sourceTransaction: sourceTx,
429
+ sourceOutputIndex: storedChange.vout,
430
+ unlockingScriptTemplate: new P2PKH().unlock(privKey),
431
+ sequence: 0xffffffff,
432
+ });
433
+ tx.addOutput({ lockingScript: opReturnScript, satoshis: 0 });
434
+
435
+ const fee = 200;
436
+ const changeSats = storedChange.satoshis - fee;
437
+ const changeVout = changeSats > 0 ? 1 : -1;
438
+ if (changeSats > 0) {
439
+ tx.addOutput({ lockingScript: new P2PKH().lock(Array.from(hash160)), satoshis: changeSats });
440
+ }
441
+
442
+ await tx.sign();
443
+ const txid = tx.id('hex');
444
+
445
+ // Build BEEF — auto-follows sourceTransaction links
446
+ const beefObj = new Beef();
447
+ beefObj.mergeTransaction(tx);
448
+ const beef = beefObj.toBinary();
449
+
450
+ // Submit to overlay
451
+ const steak = await submitToOverlay(beef, [topic]);
452
+
453
+ // Save this tx's change for next time
454
+ if (changeSats > 0) {
455
+ saveChangeBeef(tx, changeSats, 1);
456
+ }
457
+
458
+ return { txid, beef, steak, funded: 'stored-beef', changeSats };
459
+ }
460
+
461
+ /** Build a real funded transaction using WoC UTXO */
462
+ async function buildRealFundedTx(payload, topic, utxo, privKey, pubKey, hash160, walletAddress, wocBase) {
463
+ // Fetch raw source tx
464
+ const rawResp = await wocFetch(`/tx/${utxo.tx_hash}/hex`);
465
+ if (!rawResp.ok) throw new Error(`Failed to fetch source tx: ${rawResp.status}`);
466
+ const rawTxHex = await rawResp.text();
467
+ const sourceTx = Transaction.fromHex(rawTxHex);
468
+
469
+ // Fetch merkle proof for the source tx
470
+ const txInfoResp = await wocFetch(`/tx/${utxo.tx_hash}`);
471
+ const txInfo = await txInfoResp.json();
472
+ const blockHeight = txInfo.blockheight;
473
+
474
+ // Walk the source chain back to a confirmed tx with merkle proof.
475
+ // Each unconfirmed tx needs its sourceTransaction linked for BEEF building.
476
+ const txChain = []; // will contain [sourceTx, ..., provenAncestor] newest-first
477
+ let curTx = sourceTx;
478
+ let curTxid = utxo.tx_hash;
479
+ let curHeight = blockHeight;
480
+ let curConf = txInfo.confirmations;
481
+
482
+ for (let depth = 0; depth < 10; depth++) {
483
+ if (curHeight && curConf > 0) {
484
+ // Confirmed tx — attach merkle proof
485
+ const proofResp = await wocFetch(`/tx/${curTxid}/proof/tsc`);
486
+ if (proofResp.ok) {
487
+ const proofData = await proofResp.json();
488
+ if (Array.isArray(proofData) && proofData.length > 0) {
489
+ const proof = proofData[0];
490
+ curTx.merklePath = buildMerklePathFromTSC(curTxid, proof.index, proof.nodes, curHeight);
491
+ }
492
+ }
493
+ txChain.push(curTx);
494
+ break; // Found a proven tx, stop walking
495
+ }
496
+ // Unconfirmed — walk to its first input's source
497
+ txChain.push(curTx);
498
+ const parentTxid = curTx.inputs[0]?.sourceTXID;
499
+ if (!parentTxid) break;
500
+ const parentHexResp = await wocFetch(`/tx/${parentTxid}/hex`);
501
+ if (!parentHexResp.ok) break;
502
+ const parentTx = Transaction.fromHex(await parentHexResp.text());
503
+ // Link the child's input to the parent Transaction object
504
+ curTx.inputs[0].sourceTransaction = parentTx;
505
+ const parentInfoResp = await wocFetch(`/tx/${parentTxid}`);
506
+ if (!parentInfoResp.ok) break;
507
+ const parentInfo = await parentInfoResp.json();
508
+ curTx = parentTx;
509
+ curTxid = parentTxid;
510
+ curHeight = parentInfo.blockheight;
511
+ curConf = parentInfo.confirmations;
512
+ }
513
+
514
+ // Build the OP_RETURN transaction
515
+ const opReturnScript = buildOpReturnScript(payload);
516
+
517
+ const tx = new Transaction();
518
+ tx.addInput({
519
+ sourceTransaction: sourceTx,
520
+ sourceOutputIndex: utxo.tx_pos,
521
+ unlockingScriptTemplate: new P2PKH().unlock(privKey),
522
+ sequence: 0xffffffff,
523
+ });
524
+
525
+ // OP_RETURN output (0 sats)
526
+ tx.addOutput({ lockingScript: opReturnScript, satoshis: 0 });
527
+
528
+ // Change output back to our address
529
+ const pubKeyHashArr = Array.from(hash160);
530
+ const fee = 200; // generous fee for a small tx
531
+ const changeSats = utxo.value - fee;
532
+ if (changeSats > 0) {
533
+ tx.addOutput({
534
+ lockingScript: new P2PKH().lock(pubKeyHashArr),
535
+ satoshis: changeSats,
536
+ });
537
+ }
538
+
539
+ await tx.sign();
540
+ const txid = tx.id('hex');
541
+
542
+ // Build BEEF — mergeTransaction auto-follows sourceTransaction links
543
+ const srcTxRef = tx.inputs[0]?.sourceTransaction;
544
+ const beefObj = new Beef();
545
+ beefObj.mergeTransaction(tx);
546
+ const beef = beefObj.toBinary();
547
+
548
+ // Submit to overlay
549
+ const steak = await submitToOverlay(beef, [topic]);
550
+
551
+ // Save the change BEEF for instant chaining (no WoC needed next time)
552
+ if (changeSats > 0) {
553
+ saveChangeBeef(tx, changeSats, 1);
554
+ try {
555
+ await importChangeOutput(txid, tx, changeSats, 1);
556
+ } catch (importErr) {
557
+ // Non-fatal — BEEF store handles this now
558
+ }
559
+ }
560
+
561
+ return { txid, beef, steak, funded: 'real', changeSats };
562
+ }
563
+
564
+ /** Build merkle path from TSC proof data */
565
+ function buildMerklePathFromTSC(txid, txIndex, nodes, blockHeight) {
566
+ const treeHeight = nodes.length;
567
+ const mpPath = [];
568
+
569
+ // Level 0
570
+ const level0 = [{ offset: txIndex, hash: txid, txid: true }];
571
+ if (nodes[0] === '*') {
572
+ level0.push({ offset: txIndex ^ 1, duplicate: true });
573
+ } else {
574
+ level0.push({ offset: txIndex ^ 1, hash: nodes[0] });
575
+ }
576
+ level0.sort((a, b) => a.offset - b.offset);
577
+ mpPath.push(level0);
578
+
579
+ // Higher levels
580
+ for (let i = 1; i < treeHeight; i++) {
581
+ const siblingOffset = (txIndex >> i) ^ 1;
582
+ if (nodes[i] === '*') {
583
+ mpPath.push([{ offset: siblingOffset, duplicate: true }]);
584
+ } else {
585
+ mpPath.push([{ offset: siblingOffset, hash: nodes[i] }]);
586
+ }
587
+ }
588
+
589
+ return new MerklePath(blockHeight, mpPath);
590
+ }
591
+
592
+ /** Import a change output back into the wallet */
593
+ async function importChangeOutput(txid, tx, changeSats, vout) {
594
+ try {
595
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
596
+ const identityKey = await wallet.getIdentityKey();
597
+
598
+ // Build a minimal BEEF for the change output
599
+ const beef = new Beef();
600
+ beef.mergeTransaction(tx);
601
+ const atomicBeefBytes = beef.toBinaryAtomic(txid);
602
+
603
+ await wallet._setup.wallet.storage.internalizeAction({
604
+ tx: atomicBeefBytes,
605
+ outputs: [{
606
+ outputIndex: vout,
607
+ protocol: 'wallet payment',
608
+ paymentRemittance: {
609
+ derivationPrefix: Utils.toBase64(Array.from(new TextEncoder().encode('overlay-change'))),
610
+ derivationSuffix: Utils.toBase64(Array.from(new TextEncoder().encode(txid.slice(0, 16)))),
611
+ senderIdentityKey: identityKey,
612
+ },
613
+ }],
614
+ description: 'Overlay tx change',
615
+ });
616
+ await wallet.destroy();
617
+ } catch {
618
+ // Non-fatal
619
+ }
620
+ }
621
+
622
+ /** Try building via wallet's createAction (internal DB funding) */
623
+ async function buildWalletCreateActionTx(payload, topic, identity) {
624
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
625
+ const balance = await wallet.getBalance();
626
+ if (balance < 100) {
627
+ await wallet.destroy();
628
+ throw new Error(`Insufficient wallet balance (${balance} sats)`);
629
+ }
630
+
631
+ const opReturnScript = buildOpReturnScript(payload);
632
+
633
+ try {
634
+ const result = await wallet._setup.wallet.createAction({
635
+ description: 'Overlay registration',
636
+ outputs: [{
637
+ lockingScript: opReturnScript.toHex(),
638
+ satoshis: 0,
639
+ outputDescription: 'Agent overlay data',
640
+ }],
641
+ options: {
642
+ returnBEEF: true,
643
+ },
644
+ });
645
+
646
+ await wallet.destroy();
647
+
648
+ if (!result.tx) {
649
+ throw new Error('createAction returned no transaction');
650
+ }
651
+
652
+ const beef = Array.from(result.tx);
653
+ const steak = await submitToOverlay(beef, [topic]);
654
+ const parsedBeef = Beef.fromBinary(result.tx);
655
+ const lastTx = parsedBeef.txs[parsedBeef.txs.length - 1];
656
+ const txid = lastTx.txid;
657
+
658
+ return { txid, beef, steak, funded: 'wallet-internal' };
659
+ } catch (err) {
660
+ await wallet.destroy();
661
+ throw err;
662
+ }
663
+ }
664
+
665
+ /** Synthetic (unfunded) transaction — works only with SCRIPTS_ONLY overlay.
666
+ * Issue #6: Blocked on mainnet unless ALLOW_SYNTHETIC=true env var is set. */
667
+ function buildSyntheticTx(payload, privKey, pubKey) {
668
+ // Guard: never use synthetic funding on mainnet without explicit opt-in
669
+ if (NETWORK === 'mainnet' && process.env.ALLOW_SYNTHETIC !== 'true') {
670
+ throw new Error('No funds available. Import a UTXO first: overlay-cli import <txid>');
671
+ }
672
+ console.error(`[buildSyntheticTx] WARNING: Using synthetic (fabricated) funding on ${NETWORK}. This creates fake merkle proofs.`);
673
+ const pubKeyHashHex = pubKey.toHash('hex');
674
+ const pubKeyHash = [];
675
+ for (let i = 0; i < pubKeyHashHex.length; i += 2) {
676
+ pubKeyHash.push(parseInt(pubKeyHashHex.substring(i, i + 2), 16));
677
+ }
678
+
679
+ // Synthetic funding tx
680
+ const fundingTx = new Transaction();
681
+ fundingTx.addOutput({
682
+ lockingScript: new P2PKH().lock(pubKeyHash),
683
+ satoshis: 1000,
684
+ });
685
+
686
+ const fundingTxid = fundingTx.id('hex');
687
+ const siblingHash = Hash.sha256(Array.from(new TextEncoder().encode(fundingTxid)));
688
+ const siblingHex = Array.from(siblingHash).map(b => b.toString(16).padStart(2, '0')).join('');
689
+ fundingTx.merklePath = new MerklePath(1, [[
690
+ { offset: 0, hash: fundingTxid, txid: true },
691
+ { offset: 1, hash: siblingHex },
692
+ ]]);
693
+
694
+ const opReturnScript = buildOpReturnScript(payload);
695
+ const tx = new Transaction();
696
+ tx.addInput({
697
+ sourceTransaction: fundingTx,
698
+ sourceOutputIndex: 0,
699
+ unlockingScriptTemplate: new P2PKH().unlock(privKey),
700
+ sequence: 0xffffffff,
701
+ });
702
+ tx.addOutput({ lockingScript: opReturnScript, satoshis: 0 });
703
+ tx.sign();
704
+
705
+ const beef = tx.toBEEF();
706
+ const txid = tx.id('hex');
707
+ return { txid, beef, funded: 'synthetic' };
708
+ }
709
+
710
+ // ---------------------------------------------------------------------------
711
+ // Shared Payment Verification Helpers
712
+ // ---------------------------------------------------------------------------
713
+
714
+ /**
715
+ * Derive a P2PKH address from a private key.
716
+ * Centralizes address derivation logic used across multiple functions.
717
+ * @param {PrivateKey} privKey - The private key
718
+ * @returns {{ address: string, hash160: Uint8Array, pubKey: PublicKey }}
719
+ */
720
+ function deriveWalletAddress(privKey) {
721
+ const pubKey = privKey.toPublicKey();
722
+ const pubKeyBytes = pubKey.encode(true);
723
+ const hash160 = Hash.hash160(pubKeyBytes);
724
+ const prefix = NETWORK === 'mainnet' ? 0x00 : 0x6f;
725
+ const addrPayload = new Uint8Array([prefix, ...hash160]);
726
+ const checksum = Hash.hash256(Array.from(addrPayload)).slice(0, 4);
727
+ const addressBytes = new Uint8Array([...addrPayload, ...checksum]);
728
+ const address = Utils.toBase58(Array.from(addressBytes));
729
+ return { address, hash160, pubKey };
730
+ }
731
+
732
+ /**
733
+ * Fetch with timeout using AbortController.
734
+ * @param {string} url - URL to fetch
735
+ * @param {Object} options - Fetch options
736
+ * @param {number} timeoutMs - Timeout in milliseconds (default: 15000)
737
+ * @returns {Promise<Response>}
738
+ */
739
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
740
+ const controller = new AbortController();
741
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
742
+ try {
743
+ const resp = await fetch(url, { ...options, signal: controller.signal });
744
+ return resp;
745
+ } finally {
746
+ clearTimeout(timeout);
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Fetch UTXOs for an address from WhatsonChain.
752
+ * @param {string} address - The BSV address
753
+ * @param {number} minValue - Minimum UTXO value (filters dust)
754
+ * @returns {Promise<Array>} Array of UTXOs with { tx_hash, tx_pos, value }
755
+ */
756
+ async function fetchUtxosForAddress(address, minValue = 200) {
757
+ const wocNet = NETWORK === 'mainnet' ? 'main' : 'test';
758
+ const wocBase = `https://api.whatsonchain.com/v1/bsv/${wocNet}`;
759
+ const resp = await fetchWithTimeout(`${wocBase}/address/${address}/unspent`);
760
+ if (!resp.ok) throw new Error(`Failed to fetch UTXOs: ${resp.status}`);
761
+ const utxos = await resp.json();
762
+ return utxos.filter(u => u.value >= minValue);
763
+ }
764
+
765
+ /**
766
+ * Verify and accept a payment from BEEF data.
767
+ * Handles BEEF decoding, tx parsing, output matching, amount verification,
768
+ * and wallet internalization with WoC fallback.
769
+ *
770
+ * @param {Object} payment - Payment object with beef, satoshis, derivationPrefix, derivationSuffix
771
+ * @param {number} minSats - Minimum required satoshis
772
+ * @param {string} senderKey - Sender's identity key
773
+ * @param {string} serviceId - Service identifier for description
774
+ * @param {Uint8Array} recipientHash160 - Recipient's pubkey hash
775
+ * @returns {Promise<{ accepted: boolean, txid: string, satoshis: number, outputIndex: number, walletAccepted: boolean, error?: string }>}
776
+ */
777
+ async function verifyAndAcceptPayment(payment, minSats, senderKey, serviceId, recipientHash160) {
778
+ const result = {
779
+ accepted: false,
780
+ txid: null,
781
+ satoshis: 0,
782
+ outputIndex: -1,
783
+ walletAccepted: false,
784
+ error: null,
785
+ };
786
+
787
+ // Validate payment object
788
+ if (!payment || !payment.beef || !payment.satoshis) {
789
+ result.error = 'no payment';
790
+ return result;
791
+ }
792
+ if (payment.satoshis < minSats) {
793
+ result.error = `underpaid: ${payment.satoshis} < ${minSats}`;
794
+ return result;
795
+ }
796
+
797
+ // Decode BEEF
798
+ let beefBytes;
799
+ try {
800
+ beefBytes = Uint8Array.from(atob(payment.beef), c => c.charCodeAt(0));
801
+ } catch {
802
+ result.error = 'invalid base64';
803
+ return result;
804
+ }
805
+
806
+ if (!beefBytes || beefBytes.length < 20) {
807
+ result.error = 'invalid beef length';
808
+ return result;
809
+ }
810
+
811
+ // Parse the payment transaction (try AtomicBEEF first, then regular BEEF)
812
+ let paymentTx = null;
813
+ let isAtomicBeef = false;
814
+
815
+ try {
816
+ paymentTx = Transaction.fromAtomicBEEF(beefBytes);
817
+ isAtomicBeef = true;
818
+ } catch {
819
+ try {
820
+ const beefObj = Beef.fromBinary(Array.from(beefBytes));
821
+ paymentTx = beefObj.txs[beefObj.txs.length - 1];
822
+ } catch (e2) {
823
+ result.error = `beef parse failed: ${e2.message}`;
824
+ return result;
825
+ }
826
+ }
827
+
828
+ // Find the output paying us
829
+ let paymentOutputIndex = -1;
830
+ let paymentSats = 0;
831
+
832
+ for (let i = 0; i < paymentTx.outputs.length; i++) {
833
+ const out = paymentTx.outputs[i];
834
+ const chunks = out.lockingScript?.chunks || [];
835
+
836
+ // Standard P2PKH: OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG
837
+ if (chunks.length === 5 &&
838
+ chunks[0].op === 0x76 && chunks[1].op === 0xa9 &&
839
+ chunks[2].data?.length === 20 &&
840
+ chunks[3].op === 0x88 && chunks[4].op === 0xac) {
841
+ const scriptHash = new Uint8Array(chunks[2].data);
842
+ if (scriptHash.length === recipientHash160.length &&
843
+ scriptHash.every((b, idx) => b === recipientHash160[idx])) {
844
+ paymentOutputIndex = i;
845
+ paymentSats = out.satoshis;
846
+ break;
847
+ }
848
+ }
849
+ }
850
+
851
+ if (paymentOutputIndex < 0) {
852
+ result.error = 'no matching output';
853
+ return result;
854
+ }
855
+ if (paymentSats < minSats) {
856
+ result.error = `output underpaid: ${paymentSats} < ${minSats}`;
857
+ return result;
858
+ }
859
+
860
+ result.txid = paymentTx.id('hex');
861
+ result.satoshis = paymentSats;
862
+ result.outputIndex = paymentOutputIndex;
863
+ result.accepted = true;
864
+
865
+ // ── Accept payment: store the BEEF for later spending ──
866
+ // The sender's BEEF contains the full proof chain. We just need to save it
867
+ // so we can spend this output later (BEEF-first approach, no WoC needed).
868
+ try {
869
+ // Store the received payment BEEF as a spendable UTXO
870
+ const paymentStorePath = path.join(OVERLAY_STATE_DIR, 'received-payments.jsonl');
871
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
872
+
873
+ // Reconstruct the payment tx with its source chain from the BEEF
874
+ // The sender's BEEF has the full ancestry — preserve it
875
+ const entry = {
876
+ txid: result.txid,
877
+ vout: paymentOutputIndex,
878
+ satoshis: paymentSats,
879
+ beefBase64: payment.beef, // Keep the original BEEF from sender
880
+ serviceId,
881
+ from: senderKey,
882
+ ts: Date.now(),
883
+ };
884
+ fs.appendFileSync(paymentStorePath, JSON.stringify(entry) + '\n');
885
+ result.walletAccepted = true;
886
+ } catch (err) {
887
+ result.error = `payment store failed: ${err.message}`;
888
+ }
889
+
890
+ return result;
891
+ }
892
+
893
+ // ---------------------------------------------------------------------------
894
+ // State management
895
+ // ---------------------------------------------------------------------------
896
+ function loadRegistration() {
897
+ const regFile = path.join(OVERLAY_STATE_DIR, 'registration.json');
898
+ if (fs.existsSync(regFile)) {
899
+ return JSON.parse(fs.readFileSync(regFile, 'utf-8'));
900
+ }
901
+ return null;
902
+ }
903
+
904
+ function saveRegistration(data) {
905
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
906
+ const regFile = path.join(OVERLAY_STATE_DIR, 'registration.json');
907
+ fs.writeFileSync(regFile, JSON.stringify(data, null, 2), 'utf-8');
908
+ }
909
+
910
+ function loadServices() {
911
+ const svcFile = path.join(OVERLAY_STATE_DIR, 'services.json');
912
+ if (fs.existsSync(svcFile)) {
913
+ return JSON.parse(fs.readFileSync(svcFile, 'utf-8'));
914
+ }
915
+ return [];
916
+ }
917
+
918
+ function saveServices(services) {
919
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
920
+ const svcFile = path.join(OVERLAY_STATE_DIR, 'services.json');
921
+ fs.writeFileSync(svcFile, JSON.stringify(services, null, 2), 'utf-8');
922
+ }
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Wallet commands (adapted from bsv-agent-cli.mjs)
926
+ // ---------------------------------------------------------------------------
927
+
928
+ async function cmdSetup() {
929
+ if (fs.existsSync(path.join(WALLET_DIR, 'wallet-identity.json'))) {
930
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
931
+ const identityKey = await wallet.getIdentityKey();
932
+ await wallet.destroy();
933
+ return ok({
934
+ identityKey,
935
+ walletDir: WALLET_DIR,
936
+ network: NETWORK,
937
+ overlayUrl: OVERLAY_URL,
938
+ alreadyExisted: true,
939
+ });
940
+ }
941
+ fs.mkdirSync(WALLET_DIR, { recursive: true });
942
+ const wallet = await BSVAgentWallet.create({ network: NETWORK, storageDir: WALLET_DIR });
943
+ const identityKey = await wallet.getIdentityKey();
944
+ await wallet.destroy();
945
+ // Issue #8: Restrict permissions on wallet-identity.json (contains private key)
946
+ const newIdentityPath = path.join(WALLET_DIR, 'wallet-identity.json');
947
+ if (fs.existsSync(newIdentityPath)) {
948
+ fs.chmodSync(newIdentityPath, 0o600);
949
+ }
950
+ ok({
951
+ identityKey,
952
+ walletDir: WALLET_DIR,
953
+ network: NETWORK,
954
+ overlayUrl: OVERLAY_URL,
955
+ alreadyExisted: false,
956
+ });
957
+ }
958
+
959
+ async function cmdIdentity() {
960
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
961
+ const identityKey = await wallet.getIdentityKey();
962
+ await wallet.destroy();
963
+ ok({ identityKey });
964
+ }
965
+
966
+ async function cmdAddress() {
967
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
968
+ if (!fs.existsSync(identityPath)) return fail('Wallet not initialized. Run: setup');
969
+
970
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
971
+ const privKey = PrivateKey.fromHex(identity.rootKeyHex);
972
+ const pubKey = privKey.toPublicKey();
973
+ const pubKeyBytes = pubKey.encode(true);
974
+ const hash160 = Hash.hash160(pubKeyBytes);
975
+
976
+ const prefix = NETWORK === 'mainnet' ? 0x00 : 0x6f;
977
+ const payload = new Uint8Array([prefix, ...hash160]);
978
+ const checksum = Hash.hash256(Array.from(payload)).slice(0, 4);
979
+ const addressBytes = new Uint8Array([...payload, ...checksum]);
980
+ const address = Utils.toBase58(Array.from(addressBytes));
981
+
982
+ ok({
983
+ address,
984
+ network: NETWORK,
985
+ identityKey: identity.identityKey,
986
+ note: NETWORK === 'mainnet'
987
+ ? `Fund this address at an exchange — Explorer: https://whatsonchain.com/address/${address}`
988
+ : `Fund via faucet: https://witnessonchain.com/faucet/tbsv — Explorer: https://test.whatsonchain.com/address/${address}`,
989
+ });
990
+ }
991
+
992
+ async function cmdBalance() {
993
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
994
+ const total = await wallet.getBalance();
995
+ await wallet.destroy();
996
+
997
+ // Also check on-chain balance via WoC for completeness
998
+ let onChain = null;
999
+ try {
1000
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
1001
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
1002
+ const privKey = PrivateKey.fromHex(identity.rootKeyHex);
1003
+ const pubKey = privKey.toPublicKey();
1004
+ const pubKeyBytes = pubKey.encode(true);
1005
+ const hash160 = Hash.hash160(pubKeyBytes);
1006
+ const prefix = NETWORK === 'mainnet' ? 0x00 : 0x6f;
1007
+ const payload = new Uint8Array([prefix, ...hash160]);
1008
+ const checksum = Hash.hash256(Array.from(payload)).slice(0, 4);
1009
+ const addressBytes = new Uint8Array([...payload, ...checksum]);
1010
+ const address = Utils.toBase58(Array.from(addressBytes));
1011
+
1012
+ const wocNet = NETWORK === 'mainnet' ? 'main' : 'test';
1013
+ const resp = await wocFetch(`/address/${address}/balance`);
1014
+ if (resp.ok) {
1015
+ const bal = await resp.json();
1016
+ onChain = {
1017
+ address,
1018
+ confirmed: bal.confirmed,
1019
+ unconfirmed: bal.unconfirmed,
1020
+ };
1021
+ }
1022
+ } catch { /* non-fatal */ }
1023
+
1024
+ ok({ walletBalance: total, onChain });
1025
+ }
1026
+
1027
+ async function cmdImport(txidArg, voutStr) {
1028
+ if (!txidArg) return fail('Usage: import <txid> [vout]');
1029
+ const vout = parseInt(voutStr || '0', 10);
1030
+ const txid = txidArg.toLowerCase();
1031
+
1032
+ if (!/^[0-9a-f]{64}$/.test(txid)) return fail('Invalid txid — must be 64 hex characters');
1033
+
1034
+ const wocNet = NETWORK === 'mainnet' ? 'main' : 'test';
1035
+ const wocBase = `https://api.whatsonchain.com/v1/bsv/${wocNet}`;
1036
+
1037
+ // Check confirmation status
1038
+ const txInfoResp = await wocFetch(`/tx/${txid}`);
1039
+ if (!txInfoResp.ok) return fail(`Failed to fetch tx info: ${txInfoResp.status}`);
1040
+ const txInfo = await txInfoResp.json();
1041
+
1042
+ if (!txInfo.confirmations || txInfo.confirmations < 1) {
1043
+ return fail(`Transaction ${txid} is unconfirmed (${txInfo.confirmations || 0} confirmations). Wait for 1+ confirmation.`);
1044
+ }
1045
+ const blockHeight = txInfo.blockheight;
1046
+
1047
+ // Fetch raw tx
1048
+ const rawTxResp = await wocFetch(`/tx/${txid}/hex`);
1049
+ if (!rawTxResp.ok) return fail(`Failed to fetch raw tx: ${rawTxResp.status}`);
1050
+ const rawTxHex = await rawTxResp.text();
1051
+ const sourceTx = Transaction.fromHex(rawTxHex);
1052
+ const output = sourceTx.outputs[vout];
1053
+ if (!output) return fail(`Output index ${vout} not found (tx has ${sourceTx.outputs.length} outputs)`);
1054
+
1055
+ // Fetch TSC merkle proof
1056
+ const proofResp = await wocFetch(`/tx/${txid}/proof/tsc`);
1057
+ if (!proofResp.ok) return fail(`Failed to fetch merkle proof: ${proofResp.status}`);
1058
+ const proofData = await proofResp.json();
1059
+ if (!Array.isArray(proofData) || proofData.length === 0) return fail('No merkle proof available');
1060
+
1061
+ const proof = proofData[0];
1062
+ const merklePath = buildMerklePathFromTSC(txid, proof.index, proof.nodes, blockHeight);
1063
+ sourceTx.merklePath = merklePath;
1064
+
1065
+ const beef = new Beef();
1066
+ beef.mergeTransaction(sourceTx);
1067
+ const atomicBeefBytes = beef.toBinaryAtomic(txid);
1068
+
1069
+ // Import into wallet
1070
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
1071
+ const identityKey = await wallet.getIdentityKey();
1072
+
1073
+ try {
1074
+ await wallet._setup.wallet.storage.internalizeAction({
1075
+ tx: atomicBeefBytes,
1076
+ outputs: [{
1077
+ outputIndex: vout,
1078
+ protocol: 'wallet payment',
1079
+ paymentRemittance: {
1080
+ derivationPrefix: Utils.toBase64(Array.from(new TextEncoder().encode('imported'))),
1081
+ derivationSuffix: Utils.toBase64(Array.from(new TextEncoder().encode(txid.slice(0, 16)))),
1082
+ senderIdentityKey: identityKey,
1083
+ },
1084
+ }],
1085
+ description: 'External funding import',
1086
+ });
1087
+
1088
+ const balance = await wallet.getBalance();
1089
+ await wallet.destroy();
1090
+
1091
+ const explorerBase = NETWORK === 'mainnet' ? 'https://whatsonchain.com' : 'https://test.whatsonchain.com';
1092
+ ok({
1093
+ txid, vout,
1094
+ satoshis: output.satoshis,
1095
+ blockHeight,
1096
+ confirmations: txInfo.confirmations,
1097
+ imported: true,
1098
+ balance,
1099
+ explorer: `${explorerBase}/tx/${txid}`,
1100
+ });
1101
+ } catch (err) {
1102
+ await wallet.destroy();
1103
+ fail(`Failed to import UTXO: ${err instanceof Error ? err.message : String(err)}`);
1104
+ }
1105
+ }
1106
+
1107
+ async function cmdRefund(targetAddress) {
1108
+ if (!targetAddress) return fail('Usage: refund <address>');
1109
+
1110
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
1111
+ if (!fs.existsSync(identityPath)) return fail('Wallet not initialized. Run: setup');
1112
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
1113
+
1114
+ const privKey = PrivateKey.fromHex(identity.rootKeyHex);
1115
+ const pubKey = privKey.toPublicKey();
1116
+ const pubKeyBytes = pubKey.encode(true);
1117
+ const hash160 = Hash.hash160(pubKeyBytes);
1118
+ const prefix = NETWORK === 'mainnet' ? 0x00 : 0x6f;
1119
+ const payload = new Uint8Array([prefix, ...hash160]);
1120
+ const checksum = Hash.hash256(Array.from(payload)).slice(0, 4);
1121
+ const addressBytes = new Uint8Array([...payload, ...checksum]);
1122
+ const sourceAddress = Utils.toBase58(Array.from(addressBytes));
1123
+
1124
+ const wocNet = NETWORK === 'mainnet' ? 'main' : 'test';
1125
+ const wocBase = `https://api.whatsonchain.com/v1/bsv/${wocNet}`;
1126
+
1127
+ // Refund sweeps all funds — needs WoC to discover all UTXOs (manual command)
1128
+ const utxoResp = await wocFetch(`/address/${sourceAddress}/unspent`);
1129
+ if (!utxoResp.ok) return fail(`Failed to fetch UTXOs: ${utxoResp.status}`);
1130
+ const utxos = await utxoResp.json();
1131
+ if (!utxos || utxos.length === 0) return fail(`No UTXOs found for ${sourceAddress}`);
1132
+
1133
+ // Also include stored BEEF change if available (may not be on-chain yet)
1134
+ const beefStorePath = path.join(OVERLAY_STATE_DIR, 'latest-change.json');
1135
+ let storedBeefTx = null;
1136
+ let storedBeefIncluded = false;
1137
+ try {
1138
+ if (fs.existsSync(beefStorePath)) {
1139
+ const stored = JSON.parse(fs.readFileSync(beefStorePath, 'utf-8'));
1140
+ if (stored.satoshis > 0 && !utxos.some(u => u.tx_hash === stored.txid)) {
1141
+ storedBeefTx = { stored, tx: reconstructFromChain(stored) };
1142
+ }
1143
+ }
1144
+ } catch {}
1145
+
1146
+ const tx = new Transaction();
1147
+ let totalInput = 0;
1148
+
1149
+ // Add stored BEEF input first (has full source chain, no WoC needed)
1150
+ if (storedBeefTx) {
1151
+ tx.addInput({
1152
+ sourceTransaction: storedBeefTx.tx,
1153
+ sourceOutputIndex: storedBeefTx.stored.vout,
1154
+ unlockingScriptTemplate: new P2PKH().unlock(privKey),
1155
+ });
1156
+ totalInput += storedBeefTx.stored.satoshis;
1157
+ storedBeefIncluded = true;
1158
+ }
1159
+
1160
+ // Add WoC UTXOs
1161
+ const sourceTxCache = {};
1162
+ for (const utxo of utxos) {
1163
+ if (!sourceTxCache[utxo.tx_hash]) {
1164
+ const txResp = await wocFetch(`/tx/${utxo.tx_hash}/hex`);
1165
+ if (!txResp.ok) continue; // skip on error, non-fatal for sweep
1166
+ sourceTxCache[utxo.tx_hash] = await txResp.text();
1167
+ }
1168
+ const srcTx = Transaction.fromHex(sourceTxCache[utxo.tx_hash]);
1169
+ tx.addInput({
1170
+ sourceTransaction: srcTx,
1171
+ sourceOutputIndex: utxo.tx_pos,
1172
+ unlockingScriptTemplate: new P2PKH().unlock(privKey),
1173
+ });
1174
+ totalInput += utxo.value;
1175
+ }
1176
+
1177
+ if (totalInput === 0) return fail('No spendable funds found');
1178
+
1179
+ const targetDecoded = Utils.fromBase58(targetAddress);
1180
+ const targetHash160 = targetDecoded.slice(1, 21);
1181
+ tx.addOutput({
1182
+ lockingScript: new P2PKH().lock(targetHash160),
1183
+ satoshis: totalInput,
1184
+ });
1185
+
1186
+ const inputCount = tx.inputs.length;
1187
+ const estimatedSize = inputCount * 148 + 34 + 10;
1188
+ const fee = Math.max(Math.ceil(estimatedSize / 1000), 100);
1189
+ if (totalInput <= fee) return fail(`Total value (${totalInput} sats) ≤ fee (${fee} sats)`);
1190
+ tx.outputs[0].satoshis = totalInput - fee;
1191
+
1192
+ await tx.sign();
1193
+ const txid = tx.id('hex');
1194
+
1195
+ // Broadcast (required for refund — funds leave the overlay)
1196
+ const broadcastResp = await wocFetch(`/tx/raw`, {
1197
+ method: 'POST',
1198
+ headers: { 'Content-Type': 'application/json' },
1199
+ body: JSON.stringify({ txhex: tx.toHex() }),
1200
+ });
1201
+
1202
+ if (!broadcastResp.ok) {
1203
+ const errText = await broadcastResp.text();
1204
+ return fail(`Broadcast failed: ${broadcastResp.status} — ${errText}`);
1205
+ }
1206
+
1207
+ // Clear stored BEEF since we swept everything
1208
+ try { fs.unlinkSync(beefStorePath); } catch {}
1209
+
1210
+ const broadcastResult = await broadcastResp.text();
1211
+ const explorerBase = NETWORK === 'mainnet' ? 'https://whatsonchain.com' : 'https://test.whatsonchain.com';
1212
+
1213
+ ok({
1214
+ txid: broadcastResult.replace(/"/g, '').trim(),
1215
+ satoshisSent: totalInput - fee,
1216
+ fee, inputCount, totalInput,
1217
+ from: sourceAddress, to: targetAddress,
1218
+ storedBeefIncluded,
1219
+ network: NETWORK,
1220
+ explorer: `${explorerBase}/tx/${txid}`,
1221
+ });
1222
+ }
1223
+
1224
+ // ---------------------------------------------------------------------------
1225
+ // Payment commands
1226
+ // ---------------------------------------------------------------------------
1227
+
1228
+ /**
1229
+ * Build a direct P2PKH payment using on-chain UTXOs.
1230
+ * Bypasses wallet-toolbox's internal UTXO management which doesn't work
1231
+ * with externally funded P2PKH addresses.
1232
+ */
1233
+ async function buildDirectPayment(recipientPubKey, sats, desc) {
1234
+ // Validate recipient pubkey format
1235
+ if (!/^0[23][0-9a-fA-F]{64}$/.test(recipientPubKey)) {
1236
+ throw new Error('Recipient must be a compressed public key (66 hex chars starting with 02 or 03)');
1237
+ }
1238
+
1239
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
1240
+ if (!fs.existsSync(identityPath)) {
1241
+ throw new Error('Wallet not initialized. Run: overlay-cli setup');
1242
+ }
1243
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
1244
+ const privKey = PrivateKey.fromHex(identity.rootKeyHex);
1245
+ const senderPubKey = privKey.toPublicKey();
1246
+ const senderIdentityKey = identity.identityKey;
1247
+
1248
+ // Derive sender's P2PKH address
1249
+ const senderPubKeyBytes = senderPubKey.encode(true);
1250
+ const senderHash160 = Hash.hash160(senderPubKeyBytes);
1251
+ const prefix = NETWORK === 'mainnet' ? 0x00 : 0x6f;
1252
+ const addrPayload = new Uint8Array([prefix, ...senderHash160]);
1253
+ const checksum = Hash.hash256(Array.from(addrPayload)).slice(0, 4);
1254
+ const addressBytes = new Uint8Array([...addrPayload, ...checksum]);
1255
+ const senderAddress = Utils.toBase58(Array.from(addressBytes));
1256
+
1257
+ // Derive recipient's P2PKH hash from their public key
1258
+ const recipientPubKeyObj = PublicKey.fromString(recipientPubKey);
1259
+ const recipientPubKeyBytes = recipientPubKeyObj.encode(true);
1260
+ const recipientHash160 = Hash.hash160(recipientPubKeyBytes);
1261
+
1262
+ // ── BEEF-first: use stored change output (no WoC calls) ──
1263
+ const beefStorePath = path.join(OVERLAY_STATE_DIR, 'latest-change.json');
1264
+ let sourceTx = null;
1265
+ let sourceVout = -1;
1266
+ let sourceValue = 0;
1267
+
1268
+ // Try stored BEEF first
1269
+ try {
1270
+ if (fs.existsSync(beefStorePath)) {
1271
+ const stored = JSON.parse(fs.readFileSync(beefStorePath, 'utf-8'));
1272
+ if (stored.satoshis >= sats + 200) {
1273
+ sourceTx = reconstructFromChain(stored);
1274
+ sourceVout = stored.vout;
1275
+ sourceValue = stored.satoshis;
1276
+ }
1277
+ }
1278
+ } catch {}
1279
+
1280
+ // Fallback to WoC if no stored BEEF
1281
+ if (!sourceTx) {
1282
+ const utxoResp = await wocFetch(`/address/${senderAddress}/unspent`);
1283
+ if (!utxoResp.ok) throw new Error(`Failed to fetch UTXOs: ${utxoResp.status}`);
1284
+ const allUtxos = await utxoResp.json();
1285
+ const utxos = allUtxos.filter(u => u.value >= sats + 200);
1286
+ if (utxos.length === 0) throw new Error(`Insufficient funds. Need ${sats + 200} sats.`);
1287
+ const utxo = utxos[0];
1288
+
1289
+ const rawResp = await wocFetch(`/tx/${utxo.tx_hash}/hex`);
1290
+ if (!rawResp.ok) throw new Error(`Failed to fetch source tx: ${rawResp.status}`);
1291
+ sourceTx = Transaction.fromHex(await rawResp.text());
1292
+ sourceVout = utxo.tx_pos;
1293
+ sourceValue = utxo.value;
1294
+
1295
+ // Walk back for merkle proof
1296
+ let curTx = sourceTx; let curTxid = utxo.tx_hash;
1297
+ for (let depth = 0; depth < 10; depth++) {
1298
+ const infoResp = await wocFetch(`/tx/${curTxid}`);
1299
+ if (!infoResp.ok) break;
1300
+ const info = await infoResp.json();
1301
+ if (info.confirmations > 0 && info.blockheight) {
1302
+ const proofResp = await wocFetch(`/tx/${curTxid}/proof/tsc`);
1303
+ if (proofResp.ok) {
1304
+ const pd = await proofResp.json();
1305
+ if (Array.isArray(pd) && pd.length > 0) {
1306
+ curTx.merklePath = buildMerklePathFromTSC(curTxid, pd[0].index, pd[0].nodes, info.blockheight);
1307
+ }
1308
+ }
1309
+ break;
1310
+ }
1311
+ const parentTxid = curTx.inputs[0]?.sourceTXID;
1312
+ if (!parentTxid) break;
1313
+ const parentResp = await wocFetch(`/tx/${parentTxid}/hex`);
1314
+ if (!parentResp.ok) break;
1315
+ const parentTx = Transaction.fromHex(await parentResp.text());
1316
+ curTx.inputs[0].sourceTransaction = parentTx;
1317
+ curTx = parentTx; curTxid = parentTxid;
1318
+ }
1319
+ }
1320
+
1321
+ // Generate derivation info (for BRC-29 compatibility metadata)
1322
+ const derivationPrefix = Utils.toBase64(Array.from(crypto.getRandomValues(new Uint8Array(8))));
1323
+ const derivationSuffix = Utils.toBase64(Array.from(crypto.getRandomValues(new Uint8Array(8))));
1324
+
1325
+ // Build the payment transaction
1326
+ const tx = new Transaction();
1327
+ tx.addInput({
1328
+ sourceTransaction: sourceTx,
1329
+ sourceOutputIndex: sourceVout,
1330
+ unlockingScriptTemplate: new P2PKH().unlock(privKey),
1331
+ sequence: 0xffffffff,
1332
+ });
1333
+
1334
+ // Output 0: Payment to recipient
1335
+ tx.addOutput({
1336
+ lockingScript: new P2PKH().lock(recipientHash160),
1337
+ satoshis: sats,
1338
+ });
1339
+
1340
+ // Calculate fee and change
1341
+ const estimatedSize = 148 + 34 * 2 + 10; // 1 input, 2 outputs
1342
+ const fee = Math.max(Math.ceil(estimatedSize * 0.5), 50); // 0.5 sat/byte min
1343
+ const change = sourceValue - sats - fee;
1344
+
1345
+ if (change < 0) {
1346
+ throw new Error(`Insufficient funds after fee. Source: ${sourceValue}, payment: ${sats}, fee: ${fee}`);
1347
+ }
1348
+
1349
+ // Output 1: Change back to sender (if dust threshold met)
1350
+ if (change >= 136) { // P2PKH dust threshold
1351
+ tx.addOutput({
1352
+ lockingScript: new P2PKH().lock(senderHash160),
1353
+ satoshis: change,
1354
+ });
1355
+ }
1356
+
1357
+ await tx.sign();
1358
+
1359
+ // Build BEEF — auto-follows source chain
1360
+ const beef = new Beef();
1361
+ beef.mergeTransaction(tx);
1362
+
1363
+ const txid = tx.id('hex');
1364
+ const atomicBeefBytes = beef.toBinaryAtomic(txid);
1365
+ const beefBase64 = Utils.toBase64(Array.from(atomicBeefBytes));
1366
+
1367
+ // Save change BEEF for next tx (instant chaining)
1368
+ if (change >= 136) {
1369
+ saveChangeBeef(tx, change, 1);
1370
+ }
1371
+
1372
+ // Broadcast (best-effort, not required for BEEF-based delivery)
1373
+ try {
1374
+ const broadcastResp = await wocFetch(`/tx/raw`, {
1375
+ method: 'POST',
1376
+ headers: { 'Content-Type': 'application/json' },
1377
+ body: JSON.stringify({ txhex: tx.toHex() }),
1378
+ });
1379
+ if (!broadcastResp.ok) {
1380
+ console.error(`[broadcast] Non-fatal: ${broadcastResp.status}`);
1381
+ }
1382
+ } catch (bcastErr) {
1383
+ console.error(`[broadcast] Non-fatal: ${bcastErr.message}`);
1384
+ }
1385
+
1386
+ const explorerBase = NETWORK === 'mainnet' ? 'https://whatsonchain.com' : 'https://test.whatsonchain.com';
1387
+
1388
+ return {
1389
+ beef: beefBase64,
1390
+ txid,
1391
+ satoshis: sats,
1392
+ fee,
1393
+ derivationPrefix,
1394
+ derivationSuffix,
1395
+ senderIdentityKey,
1396
+ recipientIdentityKey: recipientPubKey,
1397
+ broadcast: true,
1398
+ explorer: `${explorerBase}/tx/${txid}`,
1399
+ };
1400
+ }
1401
+
1402
+ async function cmdPay(pubkey, satoshis, description) {
1403
+ if (!pubkey || !satoshis) return fail('Usage: pay <pubkey> <satoshis> [description]');
1404
+ const sats = parseInt(satoshis, 10);
1405
+ if (isNaN(sats) || sats <= 0) return fail('satoshis must be a positive integer');
1406
+
1407
+ try {
1408
+ const payment = await buildDirectPayment(pubkey, sats, description || 'agent payment');
1409
+ ok(payment);
1410
+ } catch (err) {
1411
+ fail(err instanceof Error ? err.message : String(err));
1412
+ }
1413
+ }
1414
+
1415
+ async function cmdVerify(beefBase64) {
1416
+ if (!beefBase64) return fail('Usage: verify <beef_base64>');
1417
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
1418
+ const result = wallet.verifyPayment({ beef: beefBase64 });
1419
+ await wallet.destroy();
1420
+ ok(result);
1421
+ }
1422
+
1423
+ async function cmdAccept(beef, derivationPrefix, derivationSuffix, senderIdentityKey, description) {
1424
+ if (!beef || !derivationPrefix || !derivationSuffix || !senderIdentityKey) {
1425
+ return fail('Usage: accept <beef> <prefix> <suffix> <senderKey> [description]');
1426
+ }
1427
+ const wallet = await BSVAgentWallet.load({ network: NETWORK, storageDir: WALLET_DIR });
1428
+ const receipt = await wallet.acceptPayment({
1429
+ beef, derivationPrefix, derivationSuffix, senderIdentityKey,
1430
+ description: description || undefined,
1431
+ });
1432
+ await wallet.destroy();
1433
+ ok(receipt);
1434
+ }
1435
+
1436
+ // ---------------------------------------------------------------------------
1437
+ // Overlay commands
1438
+ // ---------------------------------------------------------------------------
1439
+
1440
+ async function cmdRegister() {
1441
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
1442
+ if (!fs.existsSync(identityPath)) return fail('Wallet not initialized. Run: overlay-cli setup');
1443
+
1444
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
1445
+ const identityKey = identity.identityKey;
1446
+
1447
+ // Determine agent name
1448
+ const agentName = process.env.AGENT_NAME || os.hostname() || 'clawdbot-agent';
1449
+ const agentDesc = process.env.AGENT_DESCRIPTION
1450
+ || `Clawdbot agent (${agentName}). Offers services for BSV micropayments.`;
1451
+
1452
+ // --- Step 1: Register identity ---
1453
+ const identityPayload = {
1454
+ protocol: PROTOCOL_ID,
1455
+ type: 'identity',
1456
+ identityKey,
1457
+ name: agentName,
1458
+ description: agentDesc,
1459
+ channels: { overlay: OVERLAY_URL },
1460
+ capabilities: ['jokes', 'services'],
1461
+ timestamp: new Date().toISOString(),
1462
+ };
1463
+
1464
+ let identityResult;
1465
+ try {
1466
+ identityResult = await buildRealOverlayTransaction(identityPayload, TOPICS.IDENTITY);
1467
+ } catch (err) {
1468
+ return fail(`Identity registration failed: ${err instanceof Error ? err.message : String(err)}`);
1469
+ }
1470
+
1471
+ // --- Step 2: Register default joke service ---
1472
+ const servicePayload = {
1473
+ protocol: PROTOCOL_ID,
1474
+ type: 'service',
1475
+ identityKey,
1476
+ serviceId: 'tell-joke',
1477
+ name: 'Random Joke',
1478
+ description: 'Get a random joke. Guaranteed to be at least mildly amusing.',
1479
+ pricing: { model: 'per-task', amountSats: 5 },
1480
+ timestamp: new Date().toISOString(),
1481
+ };
1482
+
1483
+ let serviceResult;
1484
+ try {
1485
+ // For synthetic, we need a fresh tx. For real funding, we might need to wait
1486
+ // for the previous change to be available. Build synthetic for the service if needed.
1487
+ serviceResult = await buildRealOverlayTransaction(servicePayload, TOPICS.SERVICES);
1488
+ } catch (err) {
1489
+ // Service registration is non-fatal — identity is the important one
1490
+ serviceResult = { txid: null, error: String(err) };
1491
+ }
1492
+
1493
+ // Save registration state
1494
+ const registration = {
1495
+ identityKey,
1496
+ agentName,
1497
+ agentDescription: agentDesc,
1498
+ overlayUrl: OVERLAY_URL,
1499
+ identityTxid: identityResult.txid,
1500
+ serviceTxid: serviceResult?.txid || null,
1501
+ funded: identityResult.funded,
1502
+ registeredAt: new Date().toISOString(),
1503
+ };
1504
+ saveRegistration(registration);
1505
+
1506
+ // Save the joke service to local services list
1507
+ if (serviceResult?.txid) {
1508
+ const services = loadServices();
1509
+ const existing = services.findIndex(s => s.serviceId === 'tell-joke');
1510
+ const svcRecord = {
1511
+ serviceId: 'tell-joke',
1512
+ name: 'Random Joke',
1513
+ description: 'Get a random joke. Guaranteed to be at least mildly amusing.',
1514
+ priceSats: 5,
1515
+ txid: serviceResult.txid,
1516
+ registeredAt: new Date().toISOString(),
1517
+ };
1518
+ if (existing >= 0) services[existing] = svcRecord;
1519
+ else services.push(svcRecord);
1520
+ saveServices(services);
1521
+ }
1522
+
1523
+ ok({
1524
+ registered: true,
1525
+ agentName,
1526
+ identityKey,
1527
+ identityTxid: identityResult.txid,
1528
+ serviceTxid: serviceResult?.txid || null,
1529
+ funded: identityResult.funded,
1530
+ overlayUrl: OVERLAY_URL,
1531
+ stateFile: path.join(OVERLAY_STATE_DIR, 'registration.json'),
1532
+ });
1533
+ }
1534
+
1535
+ async function cmdUnregister() {
1536
+ fail('unregister is not yet implemented. Remove your agent by spending the identity UTXO.');
1537
+ }
1538
+
1539
+ async function cmdServices() {
1540
+ const services = loadServices();
1541
+ const reg = loadRegistration();
1542
+ ok({
1543
+ identityKey: reg?.identityKey || null,
1544
+ services,
1545
+ count: services.length,
1546
+ });
1547
+ }
1548
+
1549
+ async function cmdAdvertise(serviceId, name, description, priceSats) {
1550
+ if (!serviceId || !name || !description || !priceSats) {
1551
+ return fail('Usage: advertise <serviceId> <name> <description> <priceSats>');
1552
+ }
1553
+
1554
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
1555
+ if (!fs.existsSync(identityPath)) return fail('Wallet not initialized. Run: overlay-cli setup');
1556
+
1557
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
1558
+ const sats = parseInt(priceSats, 10);
1559
+ if (isNaN(sats) || sats < 0) return fail('priceSats must be a non-negative integer');
1560
+
1561
+ const servicePayload = {
1562
+ protocol: PROTOCOL_ID,
1563
+ type: 'service',
1564
+ identityKey: identity.identityKey,
1565
+ serviceId,
1566
+ name,
1567
+ description,
1568
+ pricing: { model: 'per-task', amountSats: sats },
1569
+ timestamp: new Date().toISOString(),
1570
+ };
1571
+
1572
+ let result;
1573
+ try {
1574
+ result = await buildRealOverlayTransaction(servicePayload, TOPICS.SERVICES);
1575
+ } catch (err) {
1576
+ return fail(`Service advertisement failed: ${err instanceof Error ? err.message : String(err)}`);
1577
+ }
1578
+
1579
+ // Save to local services list
1580
+ const services = loadServices();
1581
+ const existing = services.findIndex(s => s.serviceId === serviceId);
1582
+ const svcRecord = {
1583
+ serviceId, name, description, priceSats: sats,
1584
+ txid: result.txid,
1585
+ registeredAt: new Date().toISOString(),
1586
+ };
1587
+ if (existing >= 0) services[existing] = svcRecord;
1588
+ else services.push(svcRecord);
1589
+ saveServices(services);
1590
+
1591
+ ok({
1592
+ advertised: true,
1593
+ serviceId, name, description, priceSats: sats,
1594
+ txid: result.txid,
1595
+ funded: result.funded,
1596
+ });
1597
+ }
1598
+
1599
+ async function cmdRemove(serviceId) {
1600
+ if (!serviceId) return fail('Usage: remove <serviceId>');
1601
+
1602
+ // Remove from local list
1603
+ const services = loadServices();
1604
+ const idx = services.findIndex(s => s.serviceId === serviceId);
1605
+ if (idx < 0) return fail(`Service '${serviceId}' not found in local registry`);
1606
+
1607
+ const removed = services.splice(idx, 1)[0];
1608
+ saveServices(services);
1609
+
1610
+ ok({
1611
+ removed: true,
1612
+ serviceId,
1613
+ note: 'Removed from local registry. On-chain record remains until UTXO is spent.',
1614
+ previousTxid: removed.txid,
1615
+ });
1616
+ }
1617
+
1618
+ async function cmdReadvertise(serviceId, newPrice, newName, newDesc) {
1619
+ if (!serviceId || !newPrice) {
1620
+ return fail('Usage: readvertise <serviceId> <newPrice> [newName] [newDesc]');
1621
+ }
1622
+
1623
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
1624
+ if (!fs.existsSync(identityPath)) return fail('Wallet not initialized. Run: overlay-cli setup');
1625
+
1626
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
1627
+ const sats = parseInt(newPrice, 10);
1628
+ if (isNaN(sats) || sats < 0) return fail('newPrice must be a non-negative integer (satoshis)');
1629
+
1630
+ // Load existing service
1631
+ const services = loadServices();
1632
+ const existing = services.find(s => s.serviceId === serviceId);
1633
+ if (!existing) return fail(`Service '${serviceId}' not found in local registry. Use 'advertise' to create it first.`);
1634
+
1635
+ const name = newName || existing.name;
1636
+ const description = newDesc || existing.description;
1637
+
1638
+ const servicePayload = {
1639
+ protocol: PROTOCOL_ID,
1640
+ type: 'service',
1641
+ identityKey: identity.identityKey,
1642
+ serviceId,
1643
+ name,
1644
+ description,
1645
+ pricing: { model: 'per-task', amountSats: sats },
1646
+ timestamp: new Date().toISOString(),
1647
+ };
1648
+
1649
+ let result;
1650
+ try {
1651
+ result = await buildRealOverlayTransaction(servicePayload, TOPICS.SERVICES);
1652
+ } catch (err) {
1653
+ return fail(`Readvertise failed: ${err instanceof Error ? err.message : String(err)}`);
1654
+ }
1655
+
1656
+ // Update local services list
1657
+ const idx = services.findIndex(s => s.serviceId === serviceId);
1658
+ services[idx] = {
1659
+ serviceId, name, description, priceSats: sats,
1660
+ txid: result.txid,
1661
+ registeredAt: new Date().toISOString(),
1662
+ };
1663
+ saveServices(services);
1664
+
1665
+ ok({
1666
+ readvertised: true,
1667
+ serviceId, name, description, priceSats: sats,
1668
+ txid: result.txid,
1669
+ funded: result.funded,
1670
+ });
1671
+ }
1672
+
1673
+ // ---------------------------------------------------------------------------
1674
+ // Discovery commands
1675
+ // ---------------------------------------------------------------------------
1676
+
1677
+ async function cmdDiscover(args) {
1678
+ // Parse flags
1679
+ let serviceFilter = null;
1680
+ let agentFilter = null;
1681
+
1682
+ for (let i = 0; i < args.length; i++) {
1683
+ if (args[i] === '--service' && args[i + 1]) serviceFilter = args[++i];
1684
+ else if (args[i] === '--agent' && args[i + 1]) agentFilter = args[++i];
1685
+ }
1686
+
1687
+ const results = { agents: [], services: [] };
1688
+
1689
+ // Query agents
1690
+ if (!serviceFilter) {
1691
+ try {
1692
+ const agentQuery = agentFilter ? { name: agentFilter } : { type: 'list' };
1693
+ const agentResult = await lookupOverlay(LOOKUP_SERVICES.AGENTS, agentQuery);
1694
+
1695
+ if (agentResult.outputs) {
1696
+ for (const output of agentResult.outputs) {
1697
+ const data = parseOverlayOutput(output.beef, output.outputIndex);
1698
+ if (data && data.type === 'identity') {
1699
+ let txid = null;
1700
+ try {
1701
+ const tx = Transaction.fromBEEF(output.beef);
1702
+ txid = tx.id('hex');
1703
+ } catch { /* ignore */ }
1704
+ results.agents.push({ ...data, txid });
1705
+ }
1706
+ }
1707
+ }
1708
+ } catch (err) {
1709
+ results.agentError = String(err);
1710
+ }
1711
+ }
1712
+
1713
+ // Query services
1714
+ if (!agentFilter) {
1715
+ try {
1716
+ const serviceQuery = serviceFilter ? { serviceType: serviceFilter } : {};
1717
+ const serviceResult = await lookupOverlay(LOOKUP_SERVICES.SERVICES, serviceQuery);
1718
+
1719
+ if (serviceResult.outputs) {
1720
+ for (const output of serviceResult.outputs) {
1721
+ const data = parseOverlayOutput(output.beef, output.outputIndex);
1722
+ if (data && data.type === 'service') {
1723
+ let txid = null;
1724
+ try {
1725
+ const tx = Transaction.fromBEEF(output.beef);
1726
+ txid = tx.id('hex');
1727
+ } catch { /* ignore */ }
1728
+ results.services.push({ ...data, txid });
1729
+ }
1730
+ }
1731
+ }
1732
+ } catch (err) {
1733
+ results.serviceError = String(err);
1734
+ }
1735
+ }
1736
+
1737
+ ok({
1738
+ overlayUrl: OVERLAY_URL,
1739
+ agentCount: results.agents.length,
1740
+ serviceCount: results.services.length,
1741
+ agents: results.agents,
1742
+ services: results.services,
1743
+ ...(results.agentError && { agentError: results.agentError }),
1744
+ ...(results.serviceError && { serviceError: results.serviceError }),
1745
+ });
1746
+ }
1747
+
1748
+ // ---------------------------------------------------------------------------
1749
+ // Relay messaging helpers
1750
+ // ---------------------------------------------------------------------------
1751
+
1752
+ /** Get our identity key and private key from wallet. */
1753
+ function loadIdentity() {
1754
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
1755
+ if (!fs.existsSync(identityPath)) {
1756
+ throw new Error('Wallet not initialized. Run: overlay-cli setup');
1757
+ }
1758
+ // Issue #8: Warn if wallet identity file has overly permissive mode
1759
+ try {
1760
+ const fileMode = fs.statSync(identityPath).mode & 0o777;
1761
+ if (fileMode & 0o044) { // world or group readable
1762
+ console.error(`[security] WARNING: ${identityPath} has permissive mode 0${fileMode.toString(8)}. Run: chmod 600 ${identityPath}`);
1763
+ }
1764
+ } catch {}
1765
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
1766
+ const privKey = PrivateKey.fromHex(identity.rootKeyHex);
1767
+ return { identityKey: identity.identityKey, privKey };
1768
+ }
1769
+
1770
+ /** Sign a relay message: ECDSA over sha256(to + type + JSON.stringify(payload)). */
1771
+ function signRelayMessage(privKey, to, type, payload) {
1772
+ const preimage = to + type + JSON.stringify(payload);
1773
+ const msgHash = Hash.sha256(Array.from(new TextEncoder().encode(preimage)));
1774
+ const sig = privKey.sign(msgHash);
1775
+ return Array.from(sig.toDER()).map(b => b.toString(16).padStart(2, '0')).join('');
1776
+ }
1777
+
1778
+ /** Verify a relay message signature. */
1779
+ function verifyRelaySignature(fromKey, to, type, payload, signatureHex) {
1780
+ if (!signatureHex) return { valid: false, reason: 'no signature' };
1781
+ try {
1782
+ const preimage = to + type + JSON.stringify(payload);
1783
+ const msgHash = Hash.sha256(Array.from(new TextEncoder().encode(preimage)));
1784
+ const sigBytes = [];
1785
+ for (let i = 0; i < signatureHex.length; i += 2) {
1786
+ sigBytes.push(parseInt(signatureHex.substring(i, i + 2), 16));
1787
+ }
1788
+ const sig = Signature.fromDER(sigBytes);
1789
+ const pubKey = PublicKey.fromString(fromKey);
1790
+ return { valid: pubKey.verify(msgHash, sig) };
1791
+ } catch (err) {
1792
+ return { valid: false, reason: String(err) };
1793
+ }
1794
+ }
1795
+
1796
+ /**
1797
+ * Format a service response into a human-readable summary based on service type.
1798
+ * Returns an object with { type, summary, details } for notification formatting.
1799
+ */
1800
+ function formatServiceResponse(serviceId, status, result) {
1801
+ const base = { serviceId, status };
1802
+
1803
+ if (status === 'rejected') {
1804
+ return {
1805
+ ...base,
1806
+ type: 'rejection',
1807
+ summary: `Service rejected: ${result?.reason || 'unknown reason'}`,
1808
+ details: result,
1809
+ };
1810
+ }
1811
+
1812
+ switch (serviceId) {
1813
+ case 'tell-joke':
1814
+ return {
1815
+ ...base,
1816
+ type: 'joke',
1817
+ summary: result?.setup && result?.punchline
1818
+ ? `${result.setup} — ${result.punchline}`
1819
+ : 'Joke received',
1820
+ details: { setup: result?.setup, punchline: result?.punchline },
1821
+ };
1822
+
1823
+ case 'code-review':
1824
+ // Code review results include summary, findings, severity breakdown
1825
+ const findings = result?.findings || [];
1826
+ const severityCounts = findings.reduce((acc, f) => {
1827
+ acc[f.severity] = (acc[f.severity] || 0) + 1;
1828
+ return acc;
1829
+ }, {});
1830
+ const severityStr = Object.entries(severityCounts)
1831
+ .map(([k, v]) => `${v} ${k}`)
1832
+ .join(', ') || 'none';
1833
+
1834
+ return {
1835
+ ...base,
1836
+ type: 'code-review',
1837
+ summary: result?.summary || 'Code review completed',
1838
+ details: {
1839
+ findingsCount: findings.length,
1840
+ severityBreakdown: severityCounts,
1841
+ assessment: result?.assessment || result?.overallAssessment,
1842
+ findings: findings.slice(0, 5), // First 5 findings for preview
1843
+ },
1844
+ displaySummary: `Code Review: ${findings.length} findings (${severityStr}). ${result?.assessment || ''}`,
1845
+ };
1846
+
1847
+ case 'summarize':
1848
+ return {
1849
+ ...base,
1850
+ type: 'summarize',
1851
+ summary: result?.summary || 'Summary generated',
1852
+ details: {
1853
+ summary: result?.summary,
1854
+ keyPoints: result?.keyPoints,
1855
+ wordCount: result?.wordCount,
1856
+ },
1857
+ };
1858
+
1859
+ case 'translate':
1860
+ return {
1861
+ ...base,
1862
+ type: 'translate',
1863
+ summary: result?.error
1864
+ ? `Translation failed: ${result.error}`
1865
+ : `Translated (${result?.from || '?'} → ${result?.to || '?'}): "${result?.translatedText?.slice(0, 100)}${result?.translatedText?.length > 100 ? '...' : ''}"`,
1866
+ details: {
1867
+ originalText: result?.originalText,
1868
+ translatedText: result?.translatedText,
1869
+ from: result?.from,
1870
+ to: result?.to,
1871
+ provider: result?.provider,
1872
+ error: result?.error,
1873
+ },
1874
+ };
1875
+
1876
+ case 'api-proxy':
1877
+ const apiName = result?.api || 'unknown';
1878
+ let apiSummary = result?.error ? `API proxy (${apiName}) failed: ${result.error}` : `API proxy (${apiName}) completed`;
1879
+ // Add specific summary based on API type
1880
+ if (!result?.error) {
1881
+ if (apiName === 'weather' && result?.temperature) {
1882
+ apiSummary = `Weather: ${result.location} — ${result.temperature.celsius}°C, ${result.condition}`;
1883
+ } else if (apiName === 'exchange-rate' && result?.rate) {
1884
+ apiSummary = `Exchange: ${result.amount} ${result.from} = ${result.converted} ${result.to}`;
1885
+ } else if (apiName === 'crypto-price' && result?.price) {
1886
+ apiSummary = `${result.coin}: ${result.price} ${result.currency} (${result.change24h > 0 ? '+' : ''}${result.change24h?.toFixed(2)}%)`;
1887
+ } else if (apiName === 'geocode' && result?.displayName) {
1888
+ apiSummary = `Geocode: ${result.displayName?.slice(0, 80)}`;
1889
+ } else if (apiName === 'ip-lookup' && result?.ip) {
1890
+ apiSummary = `IP: ${result.ip} — ${result.city}, ${result.country}`;
1891
+ }
1892
+ }
1893
+ return {
1894
+ ...base,
1895
+ type: 'api-proxy',
1896
+ summary: apiSummary,
1897
+ details: result,
1898
+ };
1899
+
1900
+ case 'roulette':
1901
+ return {
1902
+ ...base,
1903
+ type: 'roulette',
1904
+ summary: result?.message || (result?.won ? `Won ${result.payout} sats!` : `Lost ${result.betAmount} sats`),
1905
+ details: {
1906
+ spin: result?.spin,
1907
+ color: result?.color,
1908
+ bet: result?.bet,
1909
+ betAmount: result?.betAmount,
1910
+ won: result?.won,
1911
+ payout: result?.payout,
1912
+ multiplier: result?.multiplier,
1913
+ },
1914
+ };
1915
+
1916
+ case 'memory-store':
1917
+ const op = result?.operation || 'unknown';
1918
+ let memorySummary = result?.error ? `Memory store (${op}) failed: ${result.error}` : `Memory store ${op} completed`;
1919
+ if (!result?.error) {
1920
+ if (op === 'set') {
1921
+ memorySummary = `Stored: ${result?.namespace}/${result?.key}`;
1922
+ } else if (op === 'get') {
1923
+ memorySummary = result?.found ? `Retrieved: ${result?.namespace}/${result?.key}` : `Not found: ${result?.namespace}/${result?.key}`;
1924
+ } else if (op === 'delete') {
1925
+ memorySummary = result?.deleted ? `Deleted: ${result?.namespace}/${result?.key}` : `Not found: ${result?.namespace}/${result?.key}`;
1926
+ } else if (op === 'list') {
1927
+ memorySummary = `Listed ${result?.keys?.length || 0} keys in ${result?.namespace}`;
1928
+ }
1929
+ }
1930
+ return {
1931
+ ...base,
1932
+ type: 'memory-store',
1933
+ summary: memorySummary,
1934
+ details: result,
1935
+ };
1936
+
1937
+ case 'code-develop':
1938
+ let devSummary = result?.error
1939
+ ? `Development failed: ${result.error}`
1940
+ : result?.prUrl
1941
+ ? `PR created: ${result.prUrl}`
1942
+ : 'Development completed';
1943
+ return {
1944
+ ...base,
1945
+ type: 'code-develop',
1946
+ summary: devSummary,
1947
+ details: {
1948
+ issueUrl: result?.issueUrl,
1949
+ prUrl: result?.prUrl,
1950
+ branch: result?.branch,
1951
+ commits: result?.commits,
1952
+ error: result?.error,
1953
+ },
1954
+ };
1955
+
1956
+ default:
1957
+ // Generic service response — show preview of result
1958
+ const resultPreview = result
1959
+ ? JSON.stringify(result).slice(0, 200) + (JSON.stringify(result).length > 200 ? '...' : '')
1960
+ : 'No result data';
1961
+ return {
1962
+ ...base,
1963
+ type: 'generic',
1964
+ summary: `Service '${serviceId}' completed`,
1965
+ details: result,
1966
+ resultPreview,
1967
+ };
1968
+ }
1969
+ }
1970
+
1971
+ const JOKES = [
1972
+ { setup: "Why do programmers prefer dark mode?", punchline: "Because light attracts bugs." },
1973
+ { setup: "Why did the BSV go to therapy?", punchline: "It had too many unresolved transactions." },
1974
+ { setup: "How many satoshis does it take to change a lightbulb?", punchline: "None — they prefer to stay on-chain." },
1975
+ { setup: "Why don't AI agents ever get lonely?", punchline: "They're always on the overlay." },
1976
+ { setup: "What did one Clawdbot say to the other?", punchline: "I'd tell you a joke, but it'll cost you 5 sats." },
1977
+ { setup: "Why did the blockchain break up with the database?", punchline: "It needed more commitment." },
1978
+ { setup: "What's a miner's favorite type of music?", punchline: "Block and roll." },
1979
+ { setup: "Why was the transaction so confident?", punchline: "It had six confirmations." },
1980
+ { setup: "What do you call a wallet with no UTXOs?", punchline: "A sad wallet." },
1981
+ { setup: "Why did the smart contract go to school?", punchline: "To improve its execution." },
1982
+ { setup: "How do BSV nodes say goodbye?", punchline: "See you on the next block!" },
1983
+ { setup: "Why don't private keys ever get invited to parties?", punchline: "They're too secretive." },
1984
+ { setup: "What's an overlay node's favorite game?", punchline: "Peer-to-peer tag." },
1985
+ { setup: "Why did the UTXO feel special?", punchline: "Because it was unspent." },
1986
+ { setup: "What did the signature say to the hash?", punchline: "I've got you covered." },
1987
+ ];
1988
+
1989
+ // ---------------------------------------------------------------------------
1990
+ // Relay messaging commands
1991
+ // ---------------------------------------------------------------------------
1992
+
1993
+ async function cmdSend(targetKey, type, payloadStr) {
1994
+ if (!targetKey || !type || !payloadStr) {
1995
+ return fail('Usage: send <identityKey> <type> <json_payload>');
1996
+ }
1997
+ if (!/^0[23][0-9a-fA-F]{64}$/.test(targetKey)) {
1998
+ return fail('Target must be a compressed public key (66 hex chars, 02/03 prefix)');
1999
+ }
2000
+
2001
+ let payload;
2002
+ try {
2003
+ payload = JSON.parse(payloadStr);
2004
+ } catch {
2005
+ return fail('payload must be valid JSON');
2006
+ }
2007
+
2008
+ const { identityKey, privKey } = loadIdentity();
2009
+ const signature = signRelayMessage(privKey, targetKey, type, payload);
2010
+
2011
+ const resp = await fetch(`${OVERLAY_URL}/relay/send`, {
2012
+ method: 'POST',
2013
+ headers: { 'Content-Type': 'application/json' },
2014
+ body: JSON.stringify({
2015
+ from: identityKey,
2016
+ to: targetKey,
2017
+ type,
2018
+ payload,
2019
+ signature,
2020
+ }),
2021
+ });
2022
+ if (!resp.ok) {
2023
+ const body = await resp.text();
2024
+ return fail(`Relay send failed (${resp.status}): ${body}`);
2025
+ }
2026
+ const result = await resp.json();
2027
+ ok({ sent: true, messageId: result.id, to: targetKey, type, signed: true });
2028
+ }
2029
+
2030
+ async function cmdInbox(args) {
2031
+ const { identityKey } = loadIdentity();
2032
+ let since = '';
2033
+ for (let i = 0; i < args.length; i++) {
2034
+ if (args[i] === '--since' && args[i + 1]) since = `&since=${args[++i]}`;
2035
+ }
2036
+
2037
+ const resp = await fetch(`${OVERLAY_URL}/relay/inbox?identity=${identityKey}${since}`);
2038
+ if (!resp.ok) {
2039
+ const body = await resp.text();
2040
+ return fail(`Relay inbox failed (${resp.status}): ${body}`);
2041
+ }
2042
+ const result = await resp.json();
2043
+
2044
+ // Verify signatures on received messages
2045
+ const messages = result.messages.map(msg => ({
2046
+ ...msg,
2047
+ signatureValid: msg.signature
2048
+ ? verifyRelaySignature(msg.from, msg.to, msg.type, msg.payload, msg.signature).valid
2049
+ : null,
2050
+ }));
2051
+
2052
+ ok({ messages, count: messages.length, identityKey });
2053
+ }
2054
+
2055
+ async function cmdAck(messageIds) {
2056
+ if (!messageIds || messageIds.length === 0) {
2057
+ return fail('Usage: ack <messageId> [messageId2 ...]');
2058
+ }
2059
+ const { identityKey } = loadIdentity();
2060
+
2061
+ const resp = await fetch(`${OVERLAY_URL}/relay/ack`, {
2062
+ method: 'POST',
2063
+ headers: { 'Content-Type': 'application/json' },
2064
+ body: JSON.stringify({ identity: identityKey, messageIds }),
2065
+ });
2066
+ if (!resp.ok) {
2067
+ const body = await resp.text();
2068
+ return fail(`Relay ack failed (${resp.status}): ${body}`);
2069
+ }
2070
+ const result = await resp.json();
2071
+ ok({ acked: result.acked, messageIds });
2072
+ }
2073
+
2074
+ // ---------------------------------------------------------------------------
2075
+ // Shared message processing — used by both poll and connect (WebSocket)
2076
+ // ---------------------------------------------------------------------------
2077
+
2078
+ /**
2079
+ * Process a single relay message. Handles pings, joke service requests,
2080
+ * pongs, service responses. Returns a result object.
2081
+ *
2082
+ * result.ack — whether the message should be ACKed
2083
+ * result.id — the message id
2084
+ */
2085
+ async function processMessage(msg, identityKey, privKey) {
2086
+ // Verify signature if present
2087
+ const sigCheck = msg.signature
2088
+ ? verifyRelaySignature(msg.from, msg.to, msg.type, msg.payload, msg.signature)
2089
+ : { valid: null };
2090
+
2091
+ // Issue #7: Enforce signature verification — reject unsigned/forged messages
2092
+ // Pings are harmless; service-requests and other types must have valid signatures
2093
+ if (msg.type === 'service-request' && sigCheck.valid !== true) {
2094
+ console.error(JSON.stringify({ event: 'signature-rejected', type: msg.type, from: msg.from, reason: sigCheck.reason || 'missing signature' }));
2095
+ return {
2096
+ id: msg.id, type: msg.type, from: msg.from,
2097
+ action: 'rejected', reason: 'invalid-signature',
2098
+ signatureValid: sigCheck.valid,
2099
+ ack: true,
2100
+ };
2101
+ }
2102
+
2103
+ if (msg.type === 'ping') {
2104
+ // Auto-respond with pong
2105
+ const pongPayload = {
2106
+ text: 'pong',
2107
+ inReplyTo: msg.id,
2108
+ originalText: msg.payload?.text || null,
2109
+ };
2110
+ const pongSig = signRelayMessage(privKey, msg.from, 'pong', pongPayload);
2111
+ await fetch(`${OVERLAY_URL}/relay/send`, {
2112
+ method: 'POST',
2113
+ headers: { 'Content-Type': 'application/json' },
2114
+ body: JSON.stringify({
2115
+ from: identityKey,
2116
+ to: msg.from,
2117
+ type: 'pong',
2118
+ payload: pongPayload,
2119
+ signature: pongSig,
2120
+ }),
2121
+ });
2122
+ return { id: msg.id, type: 'ping', action: 'replied-pong', from: msg.from, ack: true };
2123
+
2124
+ } else if (msg.type === 'service-request') {
2125
+ const serviceId = msg.payload?.serviceId;
2126
+
2127
+ // Agent-routed mode: queue for the agent instead of hardcoded handlers
2128
+ if (process.env.AGENT_ROUTED === 'true') {
2129
+ return await queueForAgent(msg, identityKey, privKey, serviceId);
2130
+ }
2131
+
2132
+ // Legacy hardcoded handlers (when not in agent-routed mode)
2133
+ if (serviceId === 'tell-joke') {
2134
+ return await processJokeRequest(msg, identityKey, privKey);
2135
+ } else if (serviceId === 'code-review') {
2136
+ return await processCodeReview(msg, identityKey, privKey);
2137
+ } else if (serviceId === 'web-research') {
2138
+ return await processWebResearch(msg, identityKey, privKey);
2139
+ } else if (serviceId === 'translate') {
2140
+ return await processTranslate(msg, identityKey, privKey);
2141
+ } else if (serviceId === 'api-proxy') {
2142
+ return await processApiProxy(msg, identityKey, privKey);
2143
+ } else if (serviceId === 'roulette') {
2144
+ return await processRoulette(msg, identityKey, privKey);
2145
+ } else if (serviceId === 'memory-store') {
2146
+ return await processMemoryStore(msg, identityKey, privKey);
2147
+ } else if (serviceId === 'code-develop') {
2148
+ return await processCodeDevelop(msg, identityKey, privKey);
2149
+ } else {
2150
+ // Unknown service — don't auto-process
2151
+ return {
2152
+ id: msg.id, type: 'service-request', serviceId,
2153
+ from: msg.from, signatureValid: sigCheck.valid,
2154
+ action: 'unhandled', ack: false,
2155
+ };
2156
+ }
2157
+
2158
+ } else if (msg.type === 'pong') {
2159
+ return {
2160
+ id: msg.id, type: 'pong', action: 'received', from: msg.from,
2161
+ text: msg.payload?.text, inReplyTo: msg.payload?.inReplyTo, ack: true,
2162
+ };
2163
+
2164
+ } else if (msg.type === 'service-response') {
2165
+ const serviceId = msg.payload?.serviceId;
2166
+ const status = msg.payload?.status;
2167
+ const result = msg.payload?.result;
2168
+
2169
+ // Format summary based on service type
2170
+ const formatted = formatServiceResponse(serviceId, status, result);
2171
+
2172
+ return {
2173
+ id: msg.id, type: 'service-response', action: 'received', from: msg.from,
2174
+ serviceId, status, result, requestId: msg.payload?.requestId,
2175
+ direction: 'incoming-response', // We requested, they responded
2176
+ formatted, // Human-readable summary
2177
+ ack: true,
2178
+ };
2179
+
2180
+ } else {
2181
+ // Unknown type
2182
+ return {
2183
+ id: msg.id, type: msg.type, from: msg.from,
2184
+ payload: msg.payload, signatureValid: sigCheck.valid,
2185
+ action: 'unhandled', ack: false,
2186
+ };
2187
+ }
2188
+ }
2189
+
2190
+ // ---------------------------------------------------------------------------
2191
+ // Agent-routed service queue function
2192
+ // ---------------------------------------------------------------------------
2193
+
2194
+ async function queueForAgent(msg, identityKey, privKey, serviceId) {
2195
+ const payment = msg.payload?.payment;
2196
+ const input = msg.payload?.input || msg.payload;
2197
+
2198
+ // Verify and accept payment
2199
+ let walletIdentity;
2200
+ try {
2201
+ walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
2202
+ } catch (error) {
2203
+ throw new Error(`Failed to load wallet identity: ${error.message}`);
2204
+ }
2205
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
2206
+
2207
+ // Find the service price (from local services registry)
2208
+ const services = loadServices();
2209
+ const svc = services.find(s => s.serviceId === serviceId);
2210
+ const minPrice = svc?.priceSats || 5; // default to 5 sats minimum
2211
+
2212
+ const payResult = await verifyAndAcceptPayment(payment, minPrice, msg.from, serviceId, ourHash160);
2213
+ if (!payResult.accepted) {
2214
+ // Send rejection
2215
+ const rejectPayload = { requestId: msg.id, serviceId, status: 'rejected', reason: `Payment rejected: ${payResult.error}` };
2216
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
2217
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
2218
+ method: 'POST',
2219
+ headers: { 'Content-Type': 'application/json' },
2220
+ body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }),
2221
+ });
2222
+ return { id: msg.id, type: 'service-request', serviceId, action: 'rejected', reason: payResult.error, from: msg.from, ack: true };
2223
+ }
2224
+
2225
+ // Queue for agent processing
2226
+ const queueEntry = {
2227
+ status: 'pending',
2228
+ requestId: msg.id,
2229
+ serviceId,
2230
+ from: msg.from,
2231
+ identityKey,
2232
+ input: input,
2233
+ paymentTxid: payResult.txid,
2234
+ satoshisReceived: payResult.satoshis,
2235
+ walletAccepted: payResult.walletAccepted,
2236
+ _ts: Date.now(),
2237
+ };
2238
+
2239
+ const queuePath = path.join(OVERLAY_STATE_DIR, 'service-queue.jsonl');
2240
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
2241
+ fs.appendFileSync(queuePath, JSON.stringify(queueEntry) + '\n');
2242
+
2243
+ return {
2244
+ id: msg.id, type: 'service-request', serviceId,
2245
+ action: 'queued-for-agent',
2246
+ paymentAccepted: true, paymentTxid: payResult.txid,
2247
+ satoshisReceived: payResult.satoshis,
2248
+ from: msg.from, ack: true,
2249
+ };
2250
+ }
2251
+
2252
+ // ---------------------------------------------------------------------------
2253
+ // Code-review service
2254
+ // ---------------------------------------------------------------------------
2255
+
2256
+ async function processCodeReview(msg, identityKey, privKey) {
2257
+ const PRICE = 50;
2258
+
2259
+ // Helper to send rejection
2260
+ async function reject(reason, shortReason) {
2261
+ const rejectPayload = {
2262
+ requestId: msg.id, serviceId: 'code-review', status: 'rejected', reason,
2263
+ };
2264
+ const rejectSig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
2265
+ await fetch(`${OVERLAY_URL}/relay/send`, {
2266
+ method: 'POST',
2267
+ headers: { 'Content-Type': 'application/json' },
2268
+ body: JSON.stringify({
2269
+ from: identityKey, to: msg.from, type: 'service-response',
2270
+ payload: rejectPayload, signature: rejectSig,
2271
+ }),
2272
+ });
2273
+ return {
2274
+ id: msg.id, type: 'service-request', serviceId: 'code-review',
2275
+ action: 'rejected', reason: shortReason, from: msg.from, ack: true,
2276
+ };
2277
+ }
2278
+
2279
+ // ── Payment verification via shared helper ──
2280
+ const walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
2281
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
2282
+ const payResult = await verifyAndAcceptPayment(msg.payload?.payment, PRICE, msg.from, 'code-review', ourHash160);
2283
+ if (!payResult.accepted) {
2284
+ return reject(`Payment rejected: ${payResult.error}. This service costs ${PRICE} sats.`, payResult.error);
2285
+ }
2286
+
2287
+ const paymentTxid = payResult.txid;
2288
+ const paymentSats = payResult.satoshis;
2289
+ const walletAccepted = payResult.walletAccepted;
2290
+ const acceptError = payResult.error;
2291
+
2292
+ // Perform the code review
2293
+ const input = msg.payload?.input || msg.payload;
2294
+ let reviewResult;
2295
+ try {
2296
+ reviewResult = await performCodeReview(input);
2297
+ } catch (err) {
2298
+ reviewResult = { type: 'code-review', error: `Review failed: ${err instanceof Error ? err.message : String(err)}` };
2299
+ }
2300
+
2301
+ // Send response
2302
+ const responsePayload = {
2303
+ requestId: msg.id, serviceId: 'code-review', status: 'fulfilled',
2304
+ result: reviewResult, paymentAccepted: true, paymentTxid,
2305
+ satoshisReceived: paymentSats, walletAccepted,
2306
+ ...(acceptError ? { walletError: acceptError } : {}),
2307
+ };
2308
+ const respSig = signRelayMessage(privKey, msg.from, 'service-response', responsePayload);
2309
+ await fetch(`${OVERLAY_URL}/relay/send`, {
2310
+ method: 'POST',
2311
+ headers: { 'Content-Type': 'application/json' },
2312
+ body: JSON.stringify({
2313
+ from: identityKey, to: msg.from, type: 'service-response',
2314
+ payload: responsePayload, signature: respSig,
2315
+ }),
2316
+ });
2317
+
2318
+ return {
2319
+ id: msg.id, type: 'service-request', serviceId: 'code-review',
2320
+ action: 'fulfilled', review: reviewResult, paymentAccepted: true, paymentTxid,
2321
+ satoshisReceived: paymentSats, walletAccepted,
2322
+ ...(acceptError ? { walletError: acceptError } : {}),
2323
+ from: msg.from, ack: true,
2324
+ };
2325
+ }
2326
+
2327
+ /** Perform code review on a PR URL or code snippet. */
2328
+ async function performCodeReview(input) {
2329
+ if (input.prUrl) {
2330
+ const match = input.prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
2331
+ if (!match) return { type: 'code-review', error: 'Invalid PR URL format. Expected: https://github.com/owner/repo/pull/123' };
2332
+ const [, owner, repo, prNumber] = match;
2333
+
2334
+ // Strict validation of owner/repo to prevent shell injection (Issue #4)
2335
+ const safeNameRegex = /^[a-zA-Z0-9._-]+$/;
2336
+ if (!safeNameRegex.test(owner) || !safeNameRegex.test(repo)) {
2337
+ return { type: 'code-review', error: 'Invalid owner/repo name — only alphanumeric, dots, hyphens, and underscores allowed' };
2338
+ }
2339
+ if (!/^\d+$/.test(prNumber)) {
2340
+ return { type: 'code-review', error: 'Invalid PR number — must be numeric' };
2341
+ }
2342
+
2343
+ const { execFileSync } = await import('child_process');
2344
+ let prInfo, prDiff;
2345
+ try {
2346
+ prInfo = JSON.parse(execFileSync(
2347
+ 'gh', ['pr', 'view', prNumber, '--repo', `${owner}/${repo}`, '--json', 'title,body,additions,deletions,files,author'],
2348
+ { encoding: 'utf-8', timeout: 30000 },
2349
+ ));
2350
+ } catch (e) {
2351
+ return { type: 'code-review', error: `Failed to fetch PR metadata: ${e.message}` };
2352
+ }
2353
+ try {
2354
+ prDiff = execFileSync(
2355
+ 'gh', ['pr', 'diff', prNumber, '--repo', `${owner}/${repo}`],
2356
+ { encoding: 'utf-8', maxBuffer: 2 * 1024 * 1024, timeout: 30000 },
2357
+ );
2358
+ } catch (e) {
2359
+ return { type: 'code-review', error: `Failed to fetch PR diff: ${e.message}` };
2360
+ }
2361
+
2362
+ const review = analyzePrReview(prInfo, prDiff);
2363
+
2364
+ // Post the review as a comment on the GitHub PR
2365
+ try {
2366
+ // Group findings by severity
2367
+ const bySeverity = { critical: [], high: [], warning: [], info: [] };
2368
+ for (const f of review.findings) {
2369
+ (bySeverity[f.severity] || bySeverity.info).push(f);
2370
+ }
2371
+
2372
+ let findingsText = '';
2373
+ if (review.findings.length === 0) {
2374
+ findingsText = '_No issues found._';
2375
+ } else {
2376
+ const sections = [];
2377
+ if (bySeverity.critical.length > 0) {
2378
+ sections.push('#### 🔴 Critical');
2379
+ sections.push(...bySeverity.critical.map(f => `- \`${f.file}${f.line ? ':' + f.line : ''}\` — ${f.detail}`));
2380
+ sections.push('');
2381
+ }
2382
+ if (bySeverity.high.length > 0) {
2383
+ sections.push('#### 🟠 High');
2384
+ sections.push(...bySeverity.high.map(f => `- \`${f.file}${f.line ? ':' + f.line : ''}\` — ${f.detail}`));
2385
+ sections.push('');
2386
+ }
2387
+ if (bySeverity.warning.length > 0) {
2388
+ sections.push('#### 🟡 Warnings');
2389
+ sections.push(...bySeverity.warning.map(f => `- \`${f.file}${f.line ? ':' + f.line : ''}\` — ${f.detail}`));
2390
+ sections.push('');
2391
+ }
2392
+ if (bySeverity.info.length > 0) {
2393
+ sections.push('#### ℹ️ Info');
2394
+ sections.push(...bySeverity.info.map(f => `- \`${f.file}${f.line ? ':' + f.line : ''}\` — ${f.detail}`));
2395
+ sections.push('');
2396
+ }
2397
+ findingsText = sections.join('\n');
2398
+ }
2399
+
2400
+ const suggestionsText = review.suggestions.length > 0
2401
+ ? '### Suggestions\n' + review.suggestions.map(s => `- ${s}`).join('\n')
2402
+ : '';
2403
+
2404
+ const summaryLine = review.findingsSummary
2405
+ ? `🔴 ${review.findingsSummary.critical} critical · 🟠 ${review.findingsSummary.high} high · 🟡 ${review.findingsSummary.warning} warnings · ℹ️ ${review.findingsSummary.info} info`
2406
+ : '';
2407
+
2408
+ const commentBody = [
2409
+ `## 🦉 Automated Code Review`,
2410
+ ``,
2411
+ `| | |`,
2412
+ `|---|---|`,
2413
+ `| **PR** | ${review.summary} |`,
2414
+ `| **Author** | @${review.author} |`,
2415
+ `| **Files** | ${review.filesReviewed} |`,
2416
+ `| **Changes** | ${review.linesChanged} |`,
2417
+ `| **Findings** | ${summaryLine} |`,
2418
+ ``,
2419
+ `### Findings`,
2420
+ ``,
2421
+ findingsText,
2422
+ suggestionsText,
2423
+ `### Overall Assessment`,
2424
+ ``,
2425
+ review.overallAssessment,
2426
+ ``,
2427
+ `---`,
2428
+ `_Reviewed by [BSV Overlay Skill](https://github.com/galt-tr/bsv-overlay-skill) · Paid via BSV micropayment (50 sats)_`,
2429
+ ].join('\n');
2430
+
2431
+ // Write comment to temp file to avoid shell escaping issues (Issue #4: --body-file)
2432
+ const tmpFile = path.join(os.tmpdir(), `cr-${Date.now()}.md`);
2433
+ fs.writeFileSync(tmpFile, commentBody, 'utf-8');
2434
+ execFileSync(
2435
+ 'gh', ['pr', 'comment', prNumber, '--repo', `${owner}/${repo}`, '--body-file', tmpFile],
2436
+ { encoding: 'utf-8', timeout: 15000 },
2437
+ );
2438
+ try { fs.unlinkSync(tmpFile); } catch {} // cleanup
2439
+ review.githubCommentPosted = true;
2440
+ } catch (e) {
2441
+ review.githubCommentPosted = false;
2442
+ review.githubCommentError = e instanceof Error ? e.message : String(e);
2443
+ }
2444
+
2445
+ return review;
2446
+ } else if (input.code) {
2447
+ return analyzeCodeSnippet(input.code, input.language || 'unknown');
2448
+ }
2449
+
2450
+ return { type: 'code-review', error: 'Provide either {prUrl} or {code, language} in the input.' };
2451
+ }
2452
+
2453
+ // ---------------------------------------------------------------------------
2454
+ // Service: web-research (50 sats)
2455
+ // ---------------------------------------------------------------------------
2456
+
2457
+ async function processWebResearch(msg, identityKey, privKey) {
2458
+ const PRICE = 50;
2459
+ const payment = msg.payload?.payment;
2460
+ const input = msg.payload?.input || msg.payload;
2461
+ const query = input?.query || input?.question || input?.q;
2462
+
2463
+ if (!query || typeof query !== 'string' || query.trim().length < 3) {
2464
+ // Send rejection — no valid query
2465
+ const rejectPayload = {
2466
+ requestId: msg.id,
2467
+ serviceId: 'web-research',
2468
+ status: 'rejected',
2469
+ reason: 'Missing or invalid query. Send {input: {query: "your question"}}',
2470
+ };
2471
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
2472
+ await fetch(`${OVERLAY_URL}/relay/send`, {
2473
+ method: 'POST',
2474
+ headers: { 'Content-Type': 'application/json' },
2475
+ body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }),
2476
+ });
2477
+ return { id: msg.id, type: 'service-request', serviceId: 'web-research', action: 'rejected', reason: 'no query', from: msg.from, ack: true };
2478
+ }
2479
+
2480
+ // ── Payment verification via shared helper ──
2481
+ const walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
2482
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
2483
+ const payResult = await verifyAndAcceptPayment(payment, PRICE, msg.from, 'web-research', ourHash160);
2484
+ if (!payResult.accepted) {
2485
+ const rejectPayload = { requestId: msg.id, serviceId: 'web-research', status: 'rejected', reason: `Payment rejected: ${payResult.error}. Web research costs ${PRICE} sats.` };
2486
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
2487
+ await fetch(`${OVERLAY_URL}/relay/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }) });
2488
+ return { id: msg.id, type: 'service-request', serviceId: 'web-research', action: 'rejected', reason: payResult.error, from: msg.from, ack: true };
2489
+ }
2490
+
2491
+ const paymentTxid = payResult.txid;
2492
+ const paymentSats = payResult.satoshis;
2493
+ const walletAccepted = payResult.walletAccepted;
2494
+ const acceptError = payResult.error;
2495
+
2496
+ // ── Queue the research for Clawdbot to handle (uses built-in web_search) ──
2497
+ const queueEntry = {
2498
+ type: 'pending-research',
2499
+ requestId: msg.id,
2500
+ query: query.trim(),
2501
+ from: msg.from,
2502
+ identityKey,
2503
+ paymentTxid,
2504
+ satoshisReceived: paymentSats,
2505
+ walletAccepted,
2506
+ ...(acceptError ? { walletError: acceptError } : {}),
2507
+ _ts: Date.now(),
2508
+ };
2509
+ const queuePath = path.join(OVERLAY_STATE_DIR, 'research-queue.jsonl');
2510
+ try {
2511
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
2512
+ fs.appendFileSync(queuePath, JSON.stringify(queueEntry) + '\n');
2513
+ } catch {}
2514
+
2515
+ return {
2516
+ id: msg.id,
2517
+ type: 'service-request',
2518
+ serviceId: 'web-research',
2519
+ action: 'queued',
2520
+ query: query.slice(0, 80),
2521
+ paymentAccepted: true,
2522
+ paymentTxid,
2523
+ satoshisReceived: paymentSats,
2524
+ walletAccepted,
2525
+ ...(acceptError ? { walletError: acceptError } : {}),
2526
+ from: msg.from,
2527
+ ack: true,
2528
+ };
2529
+ }
2530
+
2531
+ // ---------------------------------------------------------------------------
2532
+ // Service: translate (20 sats)
2533
+ // ---------------------------------------------------------------------------
2534
+
2535
+ async function processTranslate(msg, identityKey, privKey) {
2536
+ const PRICE = 20;
2537
+ const payment = msg.payload?.payment;
2538
+ const input = msg.payload?.input || msg.payload;
2539
+ const text = input?.text;
2540
+ const targetLang = input?.to || input?.targetLang || input?.target || 'en';
2541
+ const sourceLang = input?.from || input?.sourceLang || input?.source || 'auto';
2542
+
2543
+ // Helper to send rejection
2544
+ async function reject(reason, shortReason) {
2545
+ const rejectPayload = {
2546
+ requestId: msg.id,
2547
+ serviceId: 'translate',
2548
+ status: 'rejected',
2549
+ reason,
2550
+ };
2551
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
2552
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
2553
+ method: 'POST',
2554
+ headers: { 'Content-Type': 'application/json' },
2555
+ body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }),
2556
+ }, 15000);
2557
+ return { id: msg.id, type: 'service-request', serviceId: 'translate', action: 'rejected', reason: shortReason, from: msg.from, ack: true };
2558
+ }
2559
+
2560
+ // Validate input
2561
+ if (!text || typeof text !== 'string' || text.trim().length < 1) {
2562
+ return reject('Missing or invalid text. Send {input: {text: "your text", to: "es"}}', 'no text');
2563
+ }
2564
+ if (text.length > 5000) {
2565
+ return reject('Text too long. Maximum 5000 characters.', 'text too long');
2566
+ }
2567
+
2568
+ // ── Payment verification via shared helper ──
2569
+ const walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
2570
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
2571
+ const payResult = await verifyAndAcceptPayment(payment, PRICE, msg.from, 'translate', ourHash160);
2572
+ if (!payResult.accepted) {
2573
+ return reject(`Payment rejected: ${payResult.error}. Translation costs ${PRICE} sats.`, payResult.error);
2574
+ }
2575
+
2576
+ const paymentTxid = payResult.txid;
2577
+ const paymentSats = payResult.satoshis;
2578
+ const walletAccepted = payResult.walletAccepted;
2579
+ const acceptError = payResult.error;
2580
+
2581
+ // ── Perform the translation using LibreTranslate or MyMemory API ──
2582
+ let translationResult;
2583
+ try {
2584
+ translationResult = await performTranslation(text.trim(), sourceLang, targetLang);
2585
+ } catch (err) {
2586
+ translationResult = { error: `Translation failed: ${err instanceof Error ? err.message : String(err)}` };
2587
+ }
2588
+
2589
+ // Send response
2590
+ const responsePayload = {
2591
+ requestId: msg.id,
2592
+ serviceId: 'translate',
2593
+ status: translationResult.error ? 'partial' : 'fulfilled',
2594
+ result: translationResult,
2595
+ paymentAccepted: true,
2596
+ paymentTxid,
2597
+ satoshisReceived: paymentSats,
2598
+ walletAccepted,
2599
+ ...(acceptError ? { walletError: acceptError } : {}),
2600
+ };
2601
+ const respSig = signRelayMessage(privKey, msg.from, 'service-response', responsePayload);
2602
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
2603
+ method: 'POST',
2604
+ headers: { 'Content-Type': 'application/json' },
2605
+ body: JSON.stringify({
2606
+ from: identityKey, to: msg.from, type: 'service-response',
2607
+ payload: responsePayload, signature: respSig,
2608
+ }),
2609
+ }, 15000);
2610
+
2611
+ return {
2612
+ id: msg.id,
2613
+ type: 'service-request',
2614
+ serviceId: 'translate',
2615
+ action: translationResult.error ? 'partial' : 'fulfilled',
2616
+ translation: translationResult,
2617
+ paymentAccepted: true,
2618
+ paymentTxid,
2619
+ satoshisReceived: paymentSats,
2620
+ walletAccepted,
2621
+ direction: 'incoming-request',
2622
+ formatted: {
2623
+ type: 'translation',
2624
+ summary: translationResult.error
2625
+ ? `Translation failed: ${translationResult.error}`
2626
+ : `Translated ${sourceLang} → ${targetLang}: "${translationResult.translatedText?.slice(0, 50)}${translationResult.translatedText?.length > 50 ? '...' : ''}"`,
2627
+ earnings: paymentSats,
2628
+ },
2629
+ ...(acceptError ? { walletError: acceptError } : {}),
2630
+ from: msg.from,
2631
+ ack: true,
2632
+ };
2633
+ }
2634
+
2635
+ /**
2636
+ * Perform translation using MyMemory API (free, no API key required).
2637
+ * Falls back to a simple response if API fails.
2638
+ */
2639
+ async function performTranslation(text, sourceLang, targetLang) {
2640
+ // Normalize language codes
2641
+ const langMap = {
2642
+ 'auto': 'autodetect',
2643
+ 'en': 'en', 'english': 'en',
2644
+ 'es': 'es', 'spanish': 'es',
2645
+ 'fr': 'fr', 'french': 'fr',
2646
+ 'de': 'de', 'german': 'de',
2647
+ 'it': 'it', 'italian': 'it',
2648
+ 'pt': 'pt', 'portuguese': 'pt',
2649
+ 'ru': 'ru', 'russian': 'ru',
2650
+ 'zh': 'zh', 'chinese': 'zh',
2651
+ 'ja': 'ja', 'japanese': 'ja',
2652
+ 'ko': 'ko', 'korean': 'ko',
2653
+ 'ar': 'ar', 'arabic': 'ar',
2654
+ 'hi': 'hi', 'hindi': 'hi',
2655
+ 'nl': 'nl', 'dutch': 'nl',
2656
+ 'pl': 'pl', 'polish': 'pl',
2657
+ 'sv': 'sv', 'swedish': 'sv',
2658
+ 'tr': 'tr', 'turkish': 'tr',
2659
+ 'vi': 'vi', 'vietnamese': 'vi',
2660
+ 'th': 'th', 'thai': 'th',
2661
+ 'id': 'id', 'indonesian': 'id',
2662
+ 'cs': 'cs', 'czech': 'cs',
2663
+ 'uk': 'uk', 'ukrainian': 'uk',
2664
+ 'el': 'el', 'greek': 'el',
2665
+ 'he': 'he', 'hebrew': 'he',
2666
+ 'da': 'da', 'danish': 'da',
2667
+ 'fi': 'fi', 'finnish': 'fi',
2668
+ 'no': 'no', 'norwegian': 'no',
2669
+ 'ro': 'ro', 'romanian': 'ro',
2670
+ 'hu': 'hu', 'hungarian': 'hu',
2671
+ 'bg': 'bg', 'bulgarian': 'bg',
2672
+ 'hr': 'hr', 'croatian': 'hr',
2673
+ 'sk': 'sk', 'slovak': 'sk',
2674
+ 'sl': 'sl', 'slovenian': 'sl',
2675
+ 'et': 'et', 'estonian': 'et',
2676
+ 'lv': 'lv', 'latvian': 'lv',
2677
+ 'lt': 'lt', 'lithuanian': 'lt',
2678
+ };
2679
+
2680
+ const from = langMap[sourceLang.toLowerCase()] || sourceLang.toLowerCase().slice(0, 2);
2681
+ const to = langMap[targetLang.toLowerCase()] || targetLang.toLowerCase().slice(0, 2);
2682
+
2683
+ // Use MyMemory Translation API (free, no key required, 1000 chars/day limit per IP for anonymous)
2684
+ const langPair = from === 'autodetect' ? `|${to}` : `${from}|${to}`;
2685
+ const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${encodeURIComponent(langPair)}`;
2686
+
2687
+ try {
2688
+ const resp = await fetchWithTimeout(url, {}, 15000);
2689
+ if (!resp.ok) {
2690
+ throw new Error(`MyMemory API returned ${resp.status}`);
2691
+ }
2692
+ const data = await resp.json();
2693
+
2694
+ if (data.responseStatus === 200 && data.responseData?.translatedText) {
2695
+ const detectedLang = data.responseData?.detectedLanguage || from;
2696
+ return {
2697
+ originalText: text,
2698
+ translatedText: data.responseData.translatedText,
2699
+ from: typeof detectedLang === 'string' ? detectedLang : from,
2700
+ to: to,
2701
+ confidence: data.responseData?.match || null,
2702
+ provider: 'MyMemory',
2703
+ };
2704
+ } else {
2705
+ throw new Error(data.responseDetails || 'Translation failed');
2706
+ }
2707
+ } catch (err) {
2708
+ // Fallback: try LibreTranslate public instance
2709
+ try {
2710
+ const libreUrl = 'https://libretranslate.com/translate';
2711
+ const libreResp = await fetchWithTimeout(libreUrl, {
2712
+ method: 'POST',
2713
+ headers: { 'Content-Type': 'application/json' },
2714
+ body: JSON.stringify({
2715
+ q: text,
2716
+ source: from === 'autodetect' ? 'auto' : from,
2717
+ target: to,
2718
+ format: 'text',
2719
+ }),
2720
+ }, 15000);
2721
+
2722
+ if (libreResp.ok) {
2723
+ const libreData = await libreResp.json();
2724
+ if (libreData.translatedText) {
2725
+ return {
2726
+ originalText: text,
2727
+ translatedText: libreData.translatedText,
2728
+ from: libreData.detectedLanguage?.language || from,
2729
+ to: to,
2730
+ provider: 'LibreTranslate',
2731
+ };
2732
+ }
2733
+ }
2734
+ } catch { /* fallback failed */ }
2735
+
2736
+ // Return error
2737
+ return {
2738
+ error: err instanceof Error ? err.message : String(err),
2739
+ originalText: text,
2740
+ from: from,
2741
+ to: to,
2742
+ };
2743
+ }
2744
+ }
2745
+
2746
+ // ---------------------------------------------------------------------------
2747
+ // Service: api-proxy (15 sats)
2748
+ // ---------------------------------------------------------------------------
2749
+
2750
+ async function processApiProxy(msg, identityKey, privKey) {
2751
+ const PRICE = 15;
2752
+ const payment = msg.payload?.payment;
2753
+ const input = msg.payload?.input || msg.payload;
2754
+ const api = input?.api?.toLowerCase();
2755
+ const params = input?.params || input;
2756
+
2757
+ // Helper to send rejection
2758
+ async function reject(reason, shortReason) {
2759
+ const rejectPayload = {
2760
+ requestId: msg.id,
2761
+ serviceId: 'api-proxy',
2762
+ status: 'rejected',
2763
+ reason,
2764
+ };
2765
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
2766
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
2767
+ method: 'POST',
2768
+ headers: { 'Content-Type': 'application/json' },
2769
+ body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }),
2770
+ }, 15000);
2771
+ return { id: msg.id, type: 'service-request', serviceId: 'api-proxy', action: 'rejected', reason: shortReason, from: msg.from, ack: true };
2772
+ }
2773
+
2774
+ // Supported APIs
2775
+ const SUPPORTED_APIS = ['weather', 'geocode', 'exchange-rate', 'ip-lookup', 'crypto-price'];
2776
+
2777
+ // Validate input
2778
+ if (!api || typeof api !== 'string') {
2779
+ return reject(`Missing API name. Supported: ${SUPPORTED_APIS.join(', ')}. Send {input: {api: "weather", params: {location: "NYC"}}}`, 'no api');
2780
+ }
2781
+ if (!SUPPORTED_APIS.includes(api)) {
2782
+ return reject(`Unsupported API: ${api}. Supported: ${SUPPORTED_APIS.join(', ')}`, `unsupported api: ${api}`);
2783
+ }
2784
+
2785
+ // ── Payment verification via shared helper ──
2786
+ const walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
2787
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
2788
+ const payResult = await verifyAndAcceptPayment(payment, PRICE, msg.from, 'api-proxy', ourHash160);
2789
+ if (!payResult.accepted) {
2790
+ return reject(`Payment rejected: ${payResult.error}. API proxy costs ${PRICE} sats.`, payResult.error);
2791
+ }
2792
+
2793
+ const paymentTxid = payResult.txid;
2794
+ const paymentSats = payResult.satoshis;
2795
+ const walletAccepted = payResult.walletAccepted;
2796
+ const acceptError = payResult.error;
2797
+
2798
+ // ── Execute the API proxy request ──
2799
+ let apiResult;
2800
+ try {
2801
+ apiResult = await executeApiProxy(api, params);
2802
+ } catch (err) {
2803
+ apiResult = { error: `API call failed: ${err instanceof Error ? err.message : String(err)}` };
2804
+ }
2805
+
2806
+ // Send response
2807
+ const responsePayload = {
2808
+ requestId: msg.id,
2809
+ serviceId: 'api-proxy',
2810
+ status: apiResult.error ? 'partial' : 'fulfilled',
2811
+ result: apiResult,
2812
+ paymentAccepted: true,
2813
+ paymentTxid,
2814
+ satoshisReceived: paymentSats,
2815
+ walletAccepted,
2816
+ ...(acceptError ? { walletError: acceptError } : {}),
2817
+ };
2818
+ const respSig = signRelayMessage(privKey, msg.from, 'service-response', responsePayload);
2819
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
2820
+ method: 'POST',
2821
+ headers: { 'Content-Type': 'application/json' },
2822
+ body: JSON.stringify({
2823
+ from: identityKey, to: msg.from, type: 'service-response',
2824
+ payload: responsePayload, signature: respSig,
2825
+ }),
2826
+ }, 15000);
2827
+
2828
+ return {
2829
+ id: msg.id,
2830
+ type: 'service-request',
2831
+ serviceId: 'api-proxy',
2832
+ action: apiResult.error ? 'partial' : 'fulfilled',
2833
+ api,
2834
+ result: apiResult,
2835
+ paymentAccepted: true,
2836
+ paymentTxid,
2837
+ satoshisReceived: paymentSats,
2838
+ walletAccepted,
2839
+ direction: 'incoming-request',
2840
+ formatted: {
2841
+ type: 'api-proxy',
2842
+ summary: apiResult.error
2843
+ ? `API proxy (${api}) failed: ${apiResult.error}`
2844
+ : `API proxy (${api}) completed successfully`,
2845
+ earnings: paymentSats,
2846
+ },
2847
+ ...(acceptError ? { walletError: acceptError } : {}),
2848
+ from: msg.from,
2849
+ ack: true,
2850
+ };
2851
+ }
2852
+
2853
+ /**
2854
+ * Execute an API proxy request.
2855
+ * Supports: weather, geocode, exchange-rate, ip-lookup, crypto-price
2856
+ */
2857
+ async function executeApiProxy(api, params) {
2858
+ switch (api) {
2859
+ case 'weather':
2860
+ return await proxyWeather(params);
2861
+ case 'geocode':
2862
+ return await proxyGeocode(params);
2863
+ case 'exchange-rate':
2864
+ return await proxyExchangeRate(params);
2865
+ case 'ip-lookup':
2866
+ return await proxyIpLookup(params);
2867
+ case 'crypto-price':
2868
+ return await proxyCryptoPrice(params);
2869
+ default:
2870
+ return { error: `Unknown API: ${api}` };
2871
+ }
2872
+ }
2873
+
2874
+ /**
2875
+ * Weather API proxy using wttr.in (free, no key required)
2876
+ * Input: { location: "NYC" } or { location: "London" } or { lat, lon }
2877
+ */
2878
+ async function proxyWeather(params) {
2879
+ const location = params?.location || params?.city || params?.q;
2880
+ if (!location) {
2881
+ return { error: 'Missing location. Provide {location: "city name"} or {lat, lon}' };
2882
+ }
2883
+
2884
+ const url = `https://wttr.in/${encodeURIComponent(location)}?format=j1`;
2885
+ const resp = await fetchWithTimeout(url, {
2886
+ headers: { 'User-Agent': 'BSV-Overlay-Proxy/1.0' }
2887
+ }, 10000);
2888
+
2889
+ if (!resp.ok) {
2890
+ return { error: `Weather API returned ${resp.status}` };
2891
+ }
2892
+
2893
+ const data = await resp.json();
2894
+ const current = data.current_condition?.[0];
2895
+ const area = data.nearest_area?.[0];
2896
+
2897
+ return {
2898
+ api: 'weather',
2899
+ location: area?.areaName?.[0]?.value || location,
2900
+ country: area?.country?.[0]?.value,
2901
+ temperature: {
2902
+ celsius: parseInt(current?.temp_C) || null,
2903
+ fahrenheit: parseInt(current?.temp_F) || null,
2904
+ },
2905
+ feelsLike: {
2906
+ celsius: parseInt(current?.FeelsLikeC) || null,
2907
+ fahrenheit: parseInt(current?.FeelsLikeF) || null,
2908
+ },
2909
+ condition: current?.weatherDesc?.[0]?.value,
2910
+ humidity: `${current?.humidity}%`,
2911
+ windSpeed: {
2912
+ kmh: parseInt(current?.windspeedKmph) || null,
2913
+ mph: parseInt(current?.windspeedMiles) || null,
2914
+ },
2915
+ windDirection: current?.winddir16Point,
2916
+ visibility: `${current?.visibility} km`,
2917
+ uvIndex: current?.uvIndex,
2918
+ observationTime: current?.observation_time,
2919
+ provider: 'wttr.in',
2920
+ };
2921
+ }
2922
+
2923
+ /**
2924
+ * Geocode API proxy using Nominatim/OpenStreetMap (free, no key required)
2925
+ * Input: { address: "1600 Pennsylvania Ave, Washington DC" } for forward geocoding
2926
+ * { lat: 38.8977, lon: -77.0365 } for reverse geocoding
2927
+ */
2928
+ async function proxyGeocode(params) {
2929
+ const address = params?.address || params?.q || params?.query;
2930
+ const lat = params?.lat || params?.latitude;
2931
+ const lon = params?.lon || params?.lng || params?.longitude;
2932
+
2933
+ let url;
2934
+ let mode;
2935
+ if (address) {
2936
+ url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1&addressdetails=1`;
2937
+ mode = 'forward';
2938
+ } else if (lat && lon) {
2939
+ url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&addressdetails=1`;
2940
+ mode = 'reverse';
2941
+ } else {
2942
+ return { error: 'Provide {address: "..."} for forward geocoding or {lat, lon} for reverse geocoding' };
2943
+ }
2944
+
2945
+ const resp = await fetchWithTimeout(url, {
2946
+ headers: { 'User-Agent': 'BSV-Overlay-Proxy/1.0' }
2947
+ }, 10000);
2948
+
2949
+ if (!resp.ok) {
2950
+ return { error: `Geocode API returned ${resp.status}` };
2951
+ }
2952
+
2953
+ const data = await resp.json();
2954
+ const result = Array.isArray(data) ? data[0] : data;
2955
+
2956
+ if (!result || (Array.isArray(data) && data.length === 0)) {
2957
+ return { error: 'No results found', query: address || `${lat},${lon}` };
2958
+ }
2959
+
2960
+ return {
2961
+ api: 'geocode',
2962
+ mode,
2963
+ query: address || `${lat},${lon}`,
2964
+ lat: parseFloat(result.lat),
2965
+ lon: parseFloat(result.lon),
2966
+ displayName: result.display_name,
2967
+ address: result.address ? {
2968
+ houseNumber: result.address.house_number,
2969
+ road: result.address.road,
2970
+ city: result.address.city || result.address.town || result.address.village,
2971
+ state: result.address.state,
2972
+ postcode: result.address.postcode,
2973
+ country: result.address.country,
2974
+ countryCode: result.address.country_code?.toUpperCase(),
2975
+ } : null,
2976
+ boundingBox: result.boundingbox,
2977
+ placeId: result.place_id,
2978
+ osmType: result.osm_type,
2979
+ provider: 'Nominatim/OpenStreetMap',
2980
+ };
2981
+ }
2982
+
2983
+ /**
2984
+ * Exchange rate API proxy using exchangerate-api.com (free tier)
2985
+ * Input: { from: "USD", to: "EUR" } or { from: "USD", to: "EUR", amount: 100 }
2986
+ */
2987
+ async function proxyExchangeRate(params) {
2988
+ const from = (params?.from || params?.base || 'USD').toUpperCase();
2989
+ const to = (params?.to || params?.target || 'EUR').toUpperCase();
2990
+ const amount = parseFloat(params?.amount) || 1;
2991
+
2992
+ // Use open.er-api.com (free, no key required)
2993
+ const url = `https://open.er-api.com/v6/latest/${from}`;
2994
+ const resp = await fetchWithTimeout(url, {}, 10000);
2995
+
2996
+ if (!resp.ok) {
2997
+ return { error: `Exchange rate API returned ${resp.status}` };
2998
+ }
2999
+
3000
+ const data = await resp.json();
3001
+ if (data.result !== 'success') {
3002
+ return { error: data.error || 'Exchange rate lookup failed' };
3003
+ }
3004
+
3005
+ const rate = data.rates?.[to];
3006
+ if (!rate) {
3007
+ return { error: `Currency not found: ${to}`, availableCurrencies: Object.keys(data.rates || {}).slice(0, 20) };
3008
+ }
3009
+
3010
+ return {
3011
+ api: 'exchange-rate',
3012
+ from,
3013
+ to,
3014
+ rate,
3015
+ amount,
3016
+ converted: Math.round(amount * rate * 100) / 100,
3017
+ lastUpdate: data.time_last_update_utc,
3018
+ provider: 'open.er-api.com',
3019
+ };
3020
+ }
3021
+
3022
+ /**
3023
+ * IP lookup API proxy using ip-api.com (free, no key required)
3024
+ * Input: { ip: "8.8.8.8" } or {} for own IP
3025
+ */
3026
+ async function proxyIpLookup(params) {
3027
+ const ip = params?.ip || params?.address || '';
3028
+ const url = ip ? `http://ip-api.com/json/${ip}` : 'http://ip-api.com/json/';
3029
+
3030
+ const resp = await fetchWithTimeout(url, {}, 10000);
3031
+
3032
+ if (!resp.ok) {
3033
+ return { error: `IP lookup API returned ${resp.status}` };
3034
+ }
3035
+
3036
+ const data = await resp.json();
3037
+ if (data.status === 'fail') {
3038
+ return { error: data.message || 'IP lookup failed', query: ip };
3039
+ }
3040
+
3041
+ return {
3042
+ api: 'ip-lookup',
3043
+ ip: data.query,
3044
+ country: data.country,
3045
+ countryCode: data.countryCode,
3046
+ region: data.regionName,
3047
+ regionCode: data.region,
3048
+ city: data.city,
3049
+ zip: data.zip,
3050
+ lat: data.lat,
3051
+ lon: data.lon,
3052
+ timezone: data.timezone,
3053
+ isp: data.isp,
3054
+ org: data.org,
3055
+ as: data.as,
3056
+ provider: 'ip-api.com',
3057
+ };
3058
+ }
3059
+
3060
+ /**
3061
+ * Crypto price API proxy using CoinGecko (free, no key required)
3062
+ * Input: { coin: "bitcoin" } or { coin: "ethereum", currency: "eur" }
3063
+ */
3064
+ async function proxyCryptoPrice(params) {
3065
+ const coin = (params?.coin || params?.crypto || params?.id || 'bitcoin').toLowerCase();
3066
+ const currency = (params?.currency || params?.vs || 'usd').toLowerCase();
3067
+
3068
+ // Map common symbols to CoinGecko IDs
3069
+ const coinMap = {
3070
+ 'btc': 'bitcoin', 'eth': 'ethereum', 'bsv': 'bitcoin-sv',
3071
+ 'ltc': 'litecoin', 'xrp': 'ripple', 'doge': 'dogecoin',
3072
+ 'ada': 'cardano', 'sol': 'solana', 'dot': 'polkadot',
3073
+ 'matic': 'matic-network', 'link': 'chainlink', 'avax': 'avalanche-2',
3074
+ };
3075
+ const coinId = coinMap[coin] || coin;
3076
+
3077
+ const url = `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=${currency}&include_24hr_change=true&include_market_cap=true&include_24hr_vol=true`;
3078
+ const resp = await fetchWithTimeout(url, {}, 10000);
3079
+
3080
+ if (!resp.ok) {
3081
+ return { error: `Crypto price API returned ${resp.status}` };
3082
+ }
3083
+
3084
+ const data = await resp.json();
3085
+ const coinData = data[coinId];
3086
+
3087
+ if (!coinData) {
3088
+ return { error: `Coin not found: ${coin}`, suggestion: 'Use CoinGecko ID (e.g., "bitcoin", "ethereum", "bitcoin-sv")' };
3089
+ }
3090
+
3091
+ return {
3092
+ api: 'crypto-price',
3093
+ coin: coinId,
3094
+ currency: currency.toUpperCase(),
3095
+ price: coinData[currency],
3096
+ change24h: coinData[`${currency}_24h_change`],
3097
+ marketCap: coinData[`${currency}_market_cap`],
3098
+ volume24h: coinData[`${currency}_24h_vol`],
3099
+ provider: 'CoinGecko',
3100
+ };
3101
+ }
3102
+
3103
+ // ---------------------------------------------------------------------------
3104
+ // Service: roulette (variable sats - gambling)
3105
+ // ---------------------------------------------------------------------------
3106
+
3107
+ // Roulette wheel configuration (European single-zero)
3108
+ const ROULETTE_NUMBERS = [
3109
+ 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10,
3110
+ 5, 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26
3111
+ ];
3112
+ const RED_NUMBERS = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36];
3113
+ const BLACK_NUMBERS = [2, 4, 6, 8, 10, 11, 13, 15, 17, 20, 22, 24, 26, 28, 29, 31, 33, 35];
3114
+
3115
+ const ROULETTE_MIN_BET = 10;
3116
+ const ROULETTE_MAX_BET = 1000;
3117
+
3118
+ async function processRoulette(msg, identityKey, privKey) {
3119
+ const payment = msg.payload?.payment;
3120
+ const input = msg.payload?.input || msg.payload;
3121
+ const bet = input?.bet;
3122
+ const betAmount = payment?.satoshis || 0;
3123
+
3124
+ // Helper to send rejection
3125
+ async function reject(reason, shortReason) {
3126
+ const rejectPayload = {
3127
+ requestId: msg.id,
3128
+ serviceId: 'roulette',
3129
+ status: 'rejected',
3130
+ reason,
3131
+ };
3132
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
3133
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
3134
+ method: 'POST',
3135
+ headers: { 'Content-Type': 'application/json' },
3136
+ body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }),
3137
+ }, 15000);
3138
+ return { id: msg.id, type: 'service-request', serviceId: 'roulette', action: 'rejected', reason: shortReason, from: msg.from, ack: true };
3139
+ }
3140
+
3141
+ // Validate bet type
3142
+ const validBets = ['red', 'black', 'odd', 'even', 'low', 'high', '1st12', '2nd12', '3rd12'];
3143
+ const isNumberBet = typeof bet === 'number' && bet >= 0 && bet <= 36;
3144
+ const isNamedBet = typeof bet === 'string' && validBets.includes(bet.toLowerCase());
3145
+
3146
+ if (!isNumberBet && !isNamedBet) {
3147
+ return reject(
3148
+ `Invalid bet. Options: single number (0-36), or: ${validBets.join(', ')}. Example: {bet: "red"} or {bet: 17}`,
3149
+ 'invalid bet'
3150
+ );
3151
+ }
3152
+
3153
+ // ── Payment verification via shared helper ──
3154
+ if (betAmount > ROULETTE_MAX_BET) {
3155
+ return reject(`Maximum bet is ${ROULETTE_MAX_BET} sats. You sent ${betAmount}.`, `bet too high: ${betAmount}`);
3156
+ }
3157
+
3158
+ const walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
3159
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
3160
+ const payResult = await verifyAndAcceptPayment(payment, ROULETTE_MIN_BET, msg.from, 'roulette', ourHash160);
3161
+ if (!payResult.accepted) {
3162
+ return reject(`Payment rejected: ${payResult.error}. Place your bet (${ROULETTE_MIN_BET}-${ROULETTE_MAX_BET} sats).`, payResult.error);
3163
+ }
3164
+
3165
+ const paymentTxid = payResult.txid;
3166
+ const paymentSats = payResult.satoshis;
3167
+ const walletAccepted = payResult.walletAccepted;
3168
+ const acceptError = payResult.error;
3169
+ const actualBetAmount = Math.min(paymentSats, ROULETTE_MAX_BET);
3170
+
3171
+ // ── SPIN THE WHEEL ──
3172
+ const spinResult = spinRouletteWheel();
3173
+ const normalizedBet = isNumberBet ? bet : bet.toLowerCase();
3174
+ const { won, payout, multiplier } = evaluateRouletteBet(normalizedBet, spinResult, actualBetAmount);
3175
+
3176
+ // Determine color of result
3177
+ const resultColor = spinResult === 0 ? 'green' : (RED_NUMBERS.includes(spinResult) ? 'red' : 'black');
3178
+
3179
+ // ── If player won, pay them back ──
3180
+ let winningsPaid = false;
3181
+ let winningsPayment = null;
3182
+ let payoutError = null;
3183
+
3184
+ if (won && payout > 0) {
3185
+ try {
3186
+ winningsPayment = await buildDirectPayment(msg.from, payout, `Roulette winnings: ${normalizedBet} on ${spinResult}`);
3187
+ winningsPaid = true;
3188
+ } catch (payErr) {
3189
+ payoutError = `Failed to send winnings: ${payErr instanceof Error ? payErr.message : String(payErr)}`;
3190
+ }
3191
+ }
3192
+
3193
+ // Build result
3194
+ const gameResult = {
3195
+ spin: spinResult,
3196
+ color: resultColor,
3197
+ bet: normalizedBet,
3198
+ betAmount: actualBetAmount,
3199
+ won,
3200
+ multiplier: won ? multiplier : 0,
3201
+ payout: won ? payout : 0,
3202
+ winningsPaid,
3203
+ ...(winningsPayment ? { payoutTxid: winningsPayment.txid } : {}),
3204
+ ...(payoutError ? { payoutError } : {}),
3205
+ message: won
3206
+ ? `🎰 ${spinResult} ${resultColor.toUpperCase()}! You WIN ${payout} sats (${multiplier}x)!`
3207
+ : `🎰 ${spinResult} ${resultColor.toUpperCase()}. You lose ${actualBetAmount} sats. Better luck next time!`,
3208
+ };
3209
+
3210
+ // Send response
3211
+ const responsePayload = {
3212
+ requestId: msg.id,
3213
+ serviceId: 'roulette',
3214
+ status: 'fulfilled',
3215
+ result: gameResult,
3216
+ paymentAccepted: true,
3217
+ paymentTxid,
3218
+ satoshisReceived: paymentSats,
3219
+ walletAccepted,
3220
+ ...(acceptError ? { walletError: acceptError } : {}),
3221
+ };
3222
+ const respSig = signRelayMessage(privKey, msg.from, 'service-response', responsePayload);
3223
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
3224
+ method: 'POST',
3225
+ headers: { 'Content-Type': 'application/json' },
3226
+ body: JSON.stringify({
3227
+ from: identityKey, to: msg.from, type: 'service-response',
3228
+ payload: responsePayload, signature: respSig,
3229
+ }),
3230
+ }, 15000);
3231
+
3232
+ return {
3233
+ id: msg.id,
3234
+ type: 'service-request',
3235
+ serviceId: 'roulette',
3236
+ action: 'fulfilled',
3237
+ result: gameResult,
3238
+ paymentAccepted: true,
3239
+ paymentTxid,
3240
+ satoshisReceived: paymentSats,
3241
+ walletAccepted,
3242
+ direction: 'incoming-request',
3243
+ formatted: {
3244
+ type: 'roulette',
3245
+ summary: gameResult.message,
3246
+ earnings: won ? -payout : actualBetAmount, // negative if we paid out
3247
+ },
3248
+ ...(acceptError ? { walletError: acceptError } : {}),
3249
+ from: msg.from,
3250
+ ack: true,
3251
+ };
3252
+ }
3253
+
3254
+ /**
3255
+ * Spin the roulette wheel - returns a number 0-36
3256
+ */
3257
+ function spinRouletteWheel() {
3258
+ // Use crypto.getRandomValues for fairness
3259
+ const randomBytes = new Uint8Array(4);
3260
+ crypto.getRandomValues(randomBytes);
3261
+ const randomInt = (randomBytes[0] << 24) | (randomBytes[1] << 16) | (randomBytes[2] << 8) | randomBytes[3];
3262
+ const positiveInt = randomInt >>> 0; // Convert to unsigned
3263
+ return positiveInt % 37; // 0-36
3264
+ }
3265
+
3266
+ /**
3267
+ * Evaluate a roulette bet against the spin result
3268
+ * Returns { won: boolean, payout: number, multiplier: number }
3269
+ */
3270
+ function evaluateRouletteBet(bet, spinResult, betAmount) {
3271
+ // Single number bet (35:1)
3272
+ if (typeof bet === 'number') {
3273
+ if (bet === spinResult) {
3274
+ return { won: true, payout: betAmount * 36, multiplier: 36 }; // 35:1 + original bet
3275
+ }
3276
+ return { won: false, payout: 0, multiplier: 0 };
3277
+ }
3278
+
3279
+ // Named bets
3280
+ switch (bet) {
3281
+ case 'red':
3282
+ if (RED_NUMBERS.includes(spinResult)) {
3283
+ return { won: true, payout: betAmount * 2, multiplier: 2 };
3284
+ }
3285
+ return { won: false, payout: 0, multiplier: 0 };
3286
+
3287
+ case 'black':
3288
+ if (BLACK_NUMBERS.includes(spinResult)) {
3289
+ return { won: true, payout: betAmount * 2, multiplier: 2 };
3290
+ }
3291
+ return { won: false, payout: 0, multiplier: 0 };
3292
+
3293
+ case 'odd':
3294
+ if (spinResult > 0 && spinResult % 2 === 1) {
3295
+ return { won: true, payout: betAmount * 2, multiplier: 2 };
3296
+ }
3297
+ return { won: false, payout: 0, multiplier: 0 };
3298
+
3299
+ case 'even':
3300
+ if (spinResult > 0 && spinResult % 2 === 0) {
3301
+ return { won: true, payout: betAmount * 2, multiplier: 2 };
3302
+ }
3303
+ return { won: false, payout: 0, multiplier: 0 };
3304
+
3305
+ case 'low': // 1-18
3306
+ if (spinResult >= 1 && spinResult <= 18) {
3307
+ return { won: true, payout: betAmount * 2, multiplier: 2 };
3308
+ }
3309
+ return { won: false, payout: 0, multiplier: 0 };
3310
+
3311
+ case 'high': // 19-36
3312
+ if (spinResult >= 19 && spinResult <= 36) {
3313
+ return { won: true, payout: betAmount * 2, multiplier: 2 };
3314
+ }
3315
+ return { won: false, payout: 0, multiplier: 0 };
3316
+
3317
+ case '1st12': // 1-12
3318
+ if (spinResult >= 1 && spinResult <= 12) {
3319
+ return { won: true, payout: betAmount * 3, multiplier: 3 };
3320
+ }
3321
+ return { won: false, payout: 0, multiplier: 0 };
3322
+
3323
+ case '2nd12': // 13-24
3324
+ if (spinResult >= 13 && spinResult <= 24) {
3325
+ return { won: true, payout: betAmount * 3, multiplier: 3 };
3326
+ }
3327
+ return { won: false, payout: 0, multiplier: 0 };
3328
+
3329
+ case '3rd12': // 25-36
3330
+ if (spinResult >= 25 && spinResult <= 36) {
3331
+ return { won: true, payout: betAmount * 3, multiplier: 3 };
3332
+ }
3333
+ return { won: false, payout: 0, multiplier: 0 };
3334
+
3335
+ default:
3336
+ return { won: false, payout: 0, multiplier: 0 };
3337
+ }
3338
+ }
3339
+
3340
+ // ---------------------------------------------------------------------------
3341
+ // Service: memory-store (10 sats per operation)
3342
+ // ---------------------------------------------------------------------------
3343
+
3344
+ const MEMORY_STORE_PATH = path.join(WALLET_DIR, 'memory-store.json');
3345
+ const MEMORY_STORE_PRICE = 10;
3346
+ const MEMORY_STORE_MAX_VALUE_SIZE = 1024; // 1KB
3347
+ const MEMORY_STORE_MAX_KEYS_PER_NS = 100;
3348
+
3349
+ function loadMemoryStore() {
3350
+ try {
3351
+ if (fs.existsSync(MEMORY_STORE_PATH)) {
3352
+ return JSON.parse(fs.readFileSync(MEMORY_STORE_PATH, 'utf-8'));
3353
+ }
3354
+ } catch { /* corrupted file, start fresh */ }
3355
+ return {};
3356
+ }
3357
+
3358
+ function saveMemoryStore(store) {
3359
+ try {
3360
+ fs.writeFileSync(MEMORY_STORE_PATH, JSON.stringify(store, null, 2));
3361
+ } catch (err) {
3362
+ throw new Error(`Failed to save memory store: ${err.message}`);
3363
+ }
3364
+ }
3365
+
3366
+ async function processMemoryStore(msg, identityKey, privKey) {
3367
+ const payment = msg.payload?.payment;
3368
+ const input = msg.payload?.input || msg.payload;
3369
+ const operation = (input?.operation || input?.op || 'get').toLowerCase();
3370
+ const key = input?.key;
3371
+ const value = input?.value;
3372
+ // Default namespace is sender's pubkey (first 16 chars for readability)
3373
+ const namespace = input?.namespace || msg.from.slice(0, 16);
3374
+
3375
+ // Helper to send rejection
3376
+ async function reject(reason, shortReason) {
3377
+ const rejectPayload = {
3378
+ requestId: msg.id,
3379
+ serviceId: 'memory-store',
3380
+ status: 'rejected',
3381
+ reason,
3382
+ };
3383
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
3384
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
3385
+ method: 'POST',
3386
+ headers: { 'Content-Type': 'application/json' },
3387
+ body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }),
3388
+ }, 15000);
3389
+ return { id: msg.id, type: 'service-request', serviceId: 'memory-store', action: 'rejected', reason: shortReason, from: msg.from, ack: true };
3390
+ }
3391
+
3392
+ // Validate operation
3393
+ const validOps = ['set', 'get', 'delete', 'list'];
3394
+ if (!validOps.includes(operation)) {
3395
+ return reject(`Invalid operation. Supported: ${validOps.join(', ')}. Example: {operation: "set", key: "foo", value: "bar"}`, 'invalid operation');
3396
+ }
3397
+
3398
+ // Validate key for operations that need it
3399
+ if (['set', 'get', 'delete'].includes(operation) && (!key || typeof key !== 'string' || key.length < 1 || key.length > 64)) {
3400
+ return reject('Invalid key. Must be a string 1-64 characters.', 'invalid key');
3401
+ }
3402
+
3403
+ // Validate value for set operation
3404
+ if (operation === 'set') {
3405
+ const valueStr = typeof value === 'string' ? value : JSON.stringify(value);
3406
+ if (!value || valueStr.length > MEMORY_STORE_MAX_VALUE_SIZE) {
3407
+ return reject(`Value too large or missing. Maximum ${MEMORY_STORE_MAX_VALUE_SIZE} bytes.`, 'value too large');
3408
+ }
3409
+ }
3410
+
3411
+ // ── Payment verification ──
3412
+ let walletIdentity;
3413
+ try {
3414
+ walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
3415
+ } catch (err) {
3416
+ return reject(`Wallet identity not found or corrupted: ${err.message}`, 'wallet error');
3417
+ }
3418
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
3419
+ const payResult = await verifyAndAcceptPayment(payment, MEMORY_STORE_PRICE, msg.from, 'memory-store', ourHash160);
3420
+ if (!payResult.accepted) {
3421
+ return reject(`Payment rejected: ${payResult.error}. Memory store costs ${MEMORY_STORE_PRICE} sats per operation.`, payResult.error);
3422
+ }
3423
+
3424
+ const paymentTxid = payResult.txid;
3425
+ const paymentSats = payResult.satoshis;
3426
+ const walletAccepted = payResult.walletAccepted;
3427
+ const acceptError = payResult.error;
3428
+
3429
+ // ── Execute the operation ──
3430
+ const store = loadMemoryStore();
3431
+ if (!store[namespace]) store[namespace] = {};
3432
+ const ns = store[namespace];
3433
+
3434
+ let opResult;
3435
+
3436
+ switch (operation) {
3437
+ case 'set':
3438
+ // Check key limit
3439
+ if (Object.keys(ns).length >= MEMORY_STORE_MAX_KEYS_PER_NS && !(key in ns)) {
3440
+ opResult = { operation: 'set', error: `Namespace has reached the maximum of ${MEMORY_STORE_MAX_KEYS_PER_NS} keys.` };
3441
+ break;
3442
+ }
3443
+ ns[key] = { value, updatedAt: Date.now(), updatedBy: msg.from };
3444
+ saveMemoryStore(store);
3445
+ opResult = { operation: 'set', namespace, key, success: true, message: `Stored value for key "${key}"` };
3446
+ break;
3447
+
3448
+ case 'get':
3449
+ if (key in ns) {
3450
+ opResult = { operation: 'get', namespace, key, found: true, value: ns[key].value, updatedAt: ns[key].updatedAt };
3451
+ } else {
3452
+ opResult = { operation: 'get', namespace, key, found: false, message: `Key "${key}" not found` };
3453
+ }
3454
+ break;
3455
+
3456
+ case 'delete':
3457
+ if (key in ns) {
3458
+ delete ns[key];
3459
+ saveMemoryStore(store);
3460
+ opResult = { operation: 'delete', namespace, key, deleted: true, message: `Deleted key "${key}"` };
3461
+ } else {
3462
+ opResult = { operation: 'delete', namespace, key, deleted: false, message: `Key "${key}" not found` };
3463
+ }
3464
+ break;
3465
+
3466
+ case 'list':
3467
+ const keys = Object.keys(ns);
3468
+ opResult = { operation: 'list', namespace, keys, count: keys.length };
3469
+ break;
3470
+ }
3471
+
3472
+ // Send response
3473
+ const responsePayload = {
3474
+ requestId: msg.id,
3475
+ serviceId: 'memory-store',
3476
+ status: opResult.error ? 'partial' : 'fulfilled',
3477
+ result: opResult,
3478
+ paymentAccepted: true,
3479
+ paymentTxid,
3480
+ satoshisReceived: paymentSats,
3481
+ walletAccepted,
3482
+ ...(acceptError ? { walletError: acceptError } : {}),
3483
+ };
3484
+ const respSig = signRelayMessage(privKey, msg.from, 'service-response', responsePayload);
3485
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
3486
+ method: 'POST',
3487
+ headers: { 'Content-Type': 'application/json' },
3488
+ body: JSON.stringify({
3489
+ from: identityKey, to: msg.from, type: 'service-response',
3490
+ payload: responsePayload, signature: respSig,
3491
+ }),
3492
+ }, 15000);
3493
+
3494
+ return {
3495
+ id: msg.id,
3496
+ type: 'service-request',
3497
+ serviceId: 'memory-store',
3498
+ action: opResult.error ? 'partial' : 'fulfilled',
3499
+ result: opResult,
3500
+ paymentAccepted: true,
3501
+ paymentTxid,
3502
+ satoshisReceived: paymentSats,
3503
+ walletAccepted,
3504
+ direction: 'incoming-request',
3505
+ formatted: {
3506
+ type: 'memory-store',
3507
+ summary: opResult.error || opResult.message || `${operation} completed`,
3508
+ earnings: paymentSats,
3509
+ },
3510
+ ...(acceptError ? { walletError: acceptError } : {}),
3511
+ from: msg.from,
3512
+ ack: true,
3513
+ };
3514
+ }
3515
+
3516
+ // ---------------------------------------------------------------------------
3517
+ // Service: code-develop (100 sats) — Implement a GitHub issue and open a PR
3518
+ // ---------------------------------------------------------------------------
3519
+
3520
+ const CODE_DEVELOP_PRICE = 100;
3521
+
3522
+ async function processCodeDevelop(msg, identityKey, privKey) {
3523
+ const input = msg.payload?.input || msg.payload;
3524
+ const issueUrl = input?.issueUrl || input?.issue;
3525
+
3526
+ // Helper to send rejection
3527
+ async function reject(reason, shortReason) {
3528
+ const rejectPayload = {
3529
+ requestId: msg.id,
3530
+ serviceId: 'code-develop',
3531
+ status: 'rejected',
3532
+ reason,
3533
+ };
3534
+ const sig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
3535
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
3536
+ method: 'POST',
3537
+ headers: { 'Content-Type': 'application/json' },
3538
+ body: JSON.stringify({ from: identityKey, to: msg.from, type: 'service-response', payload: rejectPayload, signature: sig }),
3539
+ }, 15000);
3540
+ return { id: msg.id, type: 'service-request', serviceId: 'code-develop', action: 'rejected', reason: shortReason, from: msg.from, ack: true };
3541
+ }
3542
+
3543
+ // Validate issue URL
3544
+ if (!issueUrl || typeof issueUrl !== 'string') {
3545
+ return reject('Missing issue URL. Send {issueUrl: "https://github.com/owner/repo/issues/123"}', 'no issue URL');
3546
+ }
3547
+
3548
+ const issueMatch = issueUrl.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
3549
+ if (!issueMatch) {
3550
+ return reject('Invalid issue URL format. Expected: https://github.com/owner/repo/issues/123', 'invalid URL');
3551
+ }
3552
+
3553
+ const [, owner, repo, issueNumber] = issueMatch;
3554
+
3555
+ // Security: validate owner/repo names
3556
+ const safeNameRegex = /^[a-zA-Z0-9._-]+$/;
3557
+ if (!safeNameRegex.test(owner) || !safeNameRegex.test(repo)) {
3558
+ return reject('Invalid owner/repo name — only alphanumeric, dots, hyphens, and underscores allowed', 'invalid repo name');
3559
+ }
3560
+ if (!/^\d+$/.test(issueNumber)) {
3561
+ return reject('Invalid issue number — must be numeric', 'invalid issue number');
3562
+ }
3563
+
3564
+ // ── Payment verification ──
3565
+ let walletIdentity;
3566
+ try {
3567
+ walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
3568
+ } catch (err) {
3569
+ return reject(`Wallet identity not found or corrupted: ${err.message}`, 'wallet error');
3570
+ }
3571
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
3572
+ const payResult = await verifyAndAcceptPayment(msg.payload?.payment, CODE_DEVELOP_PRICE, msg.from, 'code-develop', ourHash160);
3573
+ if (!payResult.accepted) {
3574
+ return reject(`Payment rejected: ${payResult.error}. Code development costs ${CODE_DEVELOP_PRICE} sats.`, payResult.error);
3575
+ }
3576
+
3577
+ const paymentTxid = payResult.txid;
3578
+ const paymentSats = payResult.satoshis;
3579
+ const walletAccepted = payResult.walletAccepted;
3580
+ const acceptError = payResult.error;
3581
+
3582
+ // ── Perform the development work ──
3583
+ let devResult;
3584
+ try {
3585
+ devResult = await performCodeDevelopment(owner, repo, issueNumber, issueUrl);
3586
+ } catch (err) {
3587
+ devResult = { error: `Development failed: ${err instanceof Error ? err.message : String(err)}` };
3588
+ }
3589
+
3590
+ // Send response
3591
+ const responsePayload = {
3592
+ requestId: msg.id,
3593
+ serviceId: 'code-develop',
3594
+ status: devResult.error ? 'failed' : 'fulfilled',
3595
+ result: devResult,
3596
+ paymentAccepted: true,
3597
+ paymentTxid,
3598
+ satoshisReceived: paymentSats,
3599
+ walletAccepted,
3600
+ ...(acceptError ? { walletError: acceptError } : {}),
3601
+ };
3602
+ const respSig = signRelayMessage(privKey, msg.from, 'service-response', responsePayload);
3603
+ await fetchWithTimeout(`${OVERLAY_URL}/relay/send`, {
3604
+ method: 'POST',
3605
+ headers: { 'Content-Type': 'application/json' },
3606
+ body: JSON.stringify({
3607
+ from: identityKey, to: msg.from, type: 'service-response',
3608
+ payload: responsePayload, signature: respSig,
3609
+ }),
3610
+ }, 15000);
3611
+
3612
+ return {
3613
+ id: msg.id,
3614
+ type: 'service-request',
3615
+ serviceId: 'code-develop',
3616
+ action: devResult.error ? 'failed' : 'fulfilled',
3617
+ result: devResult,
3618
+ paymentAccepted: true,
3619
+ paymentTxid,
3620
+ satoshisReceived: paymentSats,
3621
+ walletAccepted,
3622
+ direction: 'incoming-request',
3623
+ formatted: {
3624
+ type: 'code-develop',
3625
+ summary: devResult.error || `PR created: ${devResult.prUrl}`,
3626
+ earnings: paymentSats,
3627
+ },
3628
+ ...(acceptError ? { walletError: acceptError } : {}),
3629
+ from: msg.from,
3630
+ ack: true,
3631
+ };
3632
+ }
3633
+
3634
+ /**
3635
+ * Perform code development: clone repo, implement issue, create PR.
3636
+ * Uses Claude Code CLI for the implementation work.
3637
+ */
3638
+ async function performCodeDevelopment(owner, repo, issueNumber, issueUrl) {
3639
+ const { execFileSync, spawnSync } = await import('child_process');
3640
+ const tmpDir = path.join(os.tmpdir(), `code-develop-${owner}-${repo}-${issueNumber}-${Date.now()}`);
3641
+
3642
+ try {
3643
+ // 1. Fetch issue details
3644
+ let issueInfo;
3645
+ try {
3646
+ issueInfo = JSON.parse(execFileSync(
3647
+ 'gh', ['issue', 'view', issueNumber, '--repo', `${owner}/${repo}`, '--json', 'title,body,labels,author'],
3648
+ { encoding: 'utf-8', timeout: 30000 },
3649
+ ));
3650
+ } catch (e) {
3651
+ return { error: `Failed to fetch issue: ${e.message}`, issueUrl };
3652
+ }
3653
+
3654
+ const issueTitle = issueInfo.title || `Issue #${issueNumber}`;
3655
+ const issueBody = issueInfo.body || '';
3656
+ const labels = (issueInfo.labels || []).map(l => l.name).join(', ');
3657
+
3658
+ // 2. Clone the repository
3659
+ try {
3660
+ fs.mkdirSync(tmpDir, { recursive: true });
3661
+ execFileSync('gh', ['repo', 'clone', `${owner}/${repo}`, tmpDir], {
3662
+ encoding: 'utf-8',
3663
+ timeout: 120000, // 2 min for large repos
3664
+ });
3665
+ } catch (e) {
3666
+ return { error: `Failed to clone repository: ${e.message}`, issueUrl };
3667
+ }
3668
+
3669
+ // 3. Create a feature branch
3670
+ const branchName = `fix/issue-${issueNumber}`;
3671
+ try {
3672
+ execFileSync('git', ['checkout', '-b', branchName], {
3673
+ cwd: tmpDir,
3674
+ encoding: 'utf-8',
3675
+ timeout: 10000,
3676
+ });
3677
+ } catch (e) {
3678
+ return { error: `Failed to create branch: ${e.message}`, issueUrl };
3679
+ }
3680
+
3681
+ // 4. Use Claude Code to implement the issue
3682
+ const prompt = `You are implementing GitHub issue #${issueNumber} for ${owner}/${repo}.
3683
+
3684
+ ## Issue Title
3685
+ ${issueTitle}
3686
+
3687
+ ## Issue Description
3688
+ ${issueBody}
3689
+
3690
+ ${labels ? `## Labels: ${labels}` : ''}
3691
+
3692
+ ## Instructions
3693
+ 1. Read the codebase to understand the project structure
3694
+ 2. Implement the changes required to resolve this issue
3695
+ 3. Follow existing code style and patterns
3696
+ 4. Add or update tests if appropriate
3697
+ 5. Make sure the code compiles/runs without errors
3698
+ 6. Commit your changes with a clear commit message referencing the issue
3699
+
3700
+ When done, your commit message should start with "fix: " or "feat: " and reference #${issueNumber}.`;
3701
+
3702
+ let claudeResult;
3703
+ try {
3704
+ // Run Claude Code with timeout (5 minutes for implementation)
3705
+ claudeResult = spawnSync('claude', ['-p', prompt], {
3706
+ cwd: tmpDir,
3707
+ encoding: 'utf-8',
3708
+ timeout: 300000, // 5 minutes
3709
+ maxBuffer: 10 * 1024 * 1024, // 10MB
3710
+ env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: 'cli' },
3711
+ });
3712
+
3713
+ if (claudeResult.status !== 0 && claudeResult.status !== null) {
3714
+ const errMsg = claudeResult.stderr || claudeResult.stdout || 'Unknown error';
3715
+ return { error: `Claude Code failed (exit ${claudeResult.status}): ${errMsg.slice(0, 500)}`, issueUrl };
3716
+ }
3717
+ } catch (e) {
3718
+ return { error: `Claude Code execution failed: ${e.message}`, issueUrl };
3719
+ }
3720
+
3721
+ // 5. Check if there are any commits
3722
+ let commitCount;
3723
+ try {
3724
+ const logResult = execFileSync('git', ['rev-list', '--count', `main..${branchName}`], {
3725
+ cwd: tmpDir,
3726
+ encoding: 'utf-8',
3727
+ timeout: 10000,
3728
+ }).trim();
3729
+ commitCount = parseInt(logResult, 10) || 0;
3730
+ } catch {
3731
+ // Try with master if main doesn't exist
3732
+ try {
3733
+ const logResult = execFileSync('git', ['rev-list', '--count', `master..${branchName}`], {
3734
+ cwd: tmpDir,
3735
+ encoding: 'utf-8',
3736
+ timeout: 10000,
3737
+ }).trim();
3738
+ commitCount = parseInt(logResult, 10) || 0;
3739
+ } catch {
3740
+ commitCount = 0;
3741
+ }
3742
+ }
3743
+
3744
+ if (commitCount === 0) {
3745
+ // No commits made - Claude didn't make changes, or made them but didn't commit
3746
+ // Try to commit any staged/unstaged changes
3747
+ try {
3748
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, timeout: 10000 });
3749
+ execFileSync('git', ['commit', '-m', `fix: implement issue #${issueNumber}\n\n${issueTitle}`], {
3750
+ cwd: tmpDir,
3751
+ encoding: 'utf-8',
3752
+ timeout: 10000,
3753
+ });
3754
+ commitCount = 1;
3755
+ } catch {
3756
+ return { error: 'No changes were made to resolve the issue', issueUrl };
3757
+ }
3758
+ }
3759
+
3760
+ // 6. Push the branch (requires gh auth or git credentials)
3761
+ try {
3762
+ execFileSync('git', ['push', '-u', 'origin', branchName], {
3763
+ cwd: tmpDir,
3764
+ encoding: 'utf-8',
3765
+ timeout: 60000,
3766
+ });
3767
+ } catch (e) {
3768
+ return { error: `Failed to push branch: ${e.message}`, issueUrl, branch: branchName, commits: commitCount };
3769
+ }
3770
+
3771
+ // 7. Create the PR
3772
+ let prUrl;
3773
+ try {
3774
+ const prBody = [
3775
+ `## Summary`,
3776
+ `This PR implements the changes requested in #${issueNumber}.`,
3777
+ ``,
3778
+ `## Issue`,
3779
+ `Closes #${issueNumber}`,
3780
+ ``,
3781
+ `## Changes`,
3782
+ `Implemented by automated code development service.`,
3783
+ ``,
3784
+ `---`,
3785
+ `_Generated by BSV Overlay code-develop service_`,
3786
+ ].join('\n');
3787
+
3788
+ const prResult = execFileSync('gh', [
3789
+ 'pr', 'create',
3790
+ '--repo', `${owner}/${repo}`,
3791
+ '--head', branchName,
3792
+ '--title', `fix: ${issueTitle}`,
3793
+ '--body', prBody,
3794
+ ], {
3795
+ cwd: tmpDir,
3796
+ encoding: 'utf-8',
3797
+ timeout: 30000,
3798
+ }).trim();
3799
+ prUrl = prResult;
3800
+ } catch (e) {
3801
+ return {
3802
+ error: `Failed to create PR: ${e.message}`,
3803
+ issueUrl,
3804
+ branch: branchName,
3805
+ commits: commitCount,
3806
+ note: 'Branch was pushed successfully. You may create the PR manually.',
3807
+ };
3808
+ }
3809
+
3810
+ return {
3811
+ issueUrl,
3812
+ issueTitle,
3813
+ prUrl,
3814
+ branch: branchName,
3815
+ commits: commitCount,
3816
+ message: `Successfully implemented issue #${issueNumber} and created PR`,
3817
+ };
3818
+
3819
+ } finally {
3820
+ // Cleanup: remove temp directory
3821
+ try {
3822
+ fs.rmSync(tmpDir, { recursive: true, force: true });
3823
+ } catch { /* ignore cleanup errors */ }
3824
+ }
3825
+ }
3826
+
3827
+ /** Analyze a GitHub PR diff for common issues. */
3828
+ function analyzePrReview(prInfo, diff) {
3829
+ const files = prInfo.files || [];
3830
+ const findings = [];
3831
+ const diffLines = diff.split('\n');
3832
+ let currentFile = '';
3833
+ let currentHunk = '';
3834
+ let addedLines = 0;
3835
+ let removedLines = 0;
3836
+ let lineNum = 0;
3837
+
3838
+ // Per-file tracking
3839
+ const fileStats = {};
3840
+ const addedBlocks = {}; // file -> array of consecutive added lines
3841
+
3842
+ for (const line of diffLines) {
3843
+ if (line.startsWith('diff --git')) {
3844
+ currentFile = line.split(' b/')[1] || '';
3845
+ if (!fileStats[currentFile]) fileStats[currentFile] = { added: 0, removed: 0, functions: [], imports: [] };
3846
+ } else if (line.startsWith('@@')) {
3847
+ currentHunk = line;
3848
+ const match = line.match(/@@ .* \+(\d+)/);
3849
+ lineNum = match ? parseInt(match[1]) - 1 : 0;
3850
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
3851
+ addedLines++;
3852
+ lineNum++;
3853
+ if (fileStats[currentFile]) fileStats[currentFile].added++;
3854
+ const trimmed = line.slice(1).trim();
3855
+
3856
+ // ── Security checks ──
3857
+ if (trimmed.includes('eval('))
3858
+ findings.push({ severity: 'critical', file: currentFile, line: lineNum, detail: '`eval()` usage — potential code injection risk' });
3859
+ if (trimmed.match(/\bexecSync\b|\bexec\b/) && trimmed.match(/\$\{|\+\s*\w/))
3860
+ findings.push({ severity: 'critical', file: currentFile, line: lineNum, detail: 'Shell command with string interpolation — injection risk' });
3861
+ if (trimmed.match(/password|secret|api_key|apikey|private_key|token/i) && !trimmed.match(/\/\/|^\s*\*|param|type|interface|@/))
3862
+ findings.push({ severity: 'high', file: currentFile, line: lineNum, detail: 'Possible hardcoded secret or credential' });
3863
+ if (trimmed.match(/https?:\/\/\d+\.\d+\.\d+\.\d+/))
3864
+ findings.push({ severity: 'warning', file: currentFile, line: lineNum, detail: 'Hardcoded IP address — use config/env variable' });
3865
+ if (trimmed.includes('dangerouslySetInnerHTML') || trimmed.includes('innerHTML'))
3866
+ findings.push({ severity: 'high', file: currentFile, line: lineNum, detail: 'Direct HTML injection — XSS risk' });
3867
+
3868
+ // ── Error handling ──
3869
+ if (trimmed.match(/catch\s*\([^)]*\)\s*\{\s*\}/) || trimmed.match(/catch\s*\{\s*\}/))
3870
+ findings.push({ severity: 'warning', file: currentFile, line: lineNum, detail: 'Empty catch block — errors silently swallowed' });
3871
+ if (trimmed.match(/catch\s*\([^)]*\)\s*\{\s*\/\//))
3872
+ findings.push({ severity: 'info', file: currentFile, line: lineNum, detail: 'Catch block with only a comment — consider logging the error' });
3873
+ if (trimmed.match(/\.then\(/) && !trimmed.match(/\.catch\(/))
3874
+ findings.push({ severity: 'info', file: currentFile, line: lineNum, detail: 'Promise .then() without .catch() — unhandled rejection risk' });
3875
+
3876
+ // ── Code quality ──
3877
+ if (trimmed.match(/console\.(log|debug|info)\(/))
3878
+ findings.push({ severity: 'warning', file: currentFile, line: lineNum, detail: 'Debug logging left in — remove before merge' });
3879
+ if (trimmed.match(/TODO|FIXME|HACK|XXX|TEMP/i))
3880
+ findings.push({ severity: 'info', file: currentFile, line: lineNum, detail: `Marker comment: ${trimmed.slice(0, 100)}` });
3881
+ if (trimmed.includes('var ') && !trimmed.match(/\/\/|^\s*\*/))
3882
+ findings.push({ severity: 'info', file: currentFile, line: lineNum, detail: '`var` declaration — prefer `let` or `const`' });
3883
+ if (line.length > 200)
3884
+ findings.push({ severity: 'info', file: currentFile, line: lineNum, detail: `Line too long (${line.length} chars)` });
3885
+ if (trimmed.match(/==\s/) && !trimmed.match(/===/) && !trimmed.match(/!==/) && !trimmed.match(/\/\//))
3886
+ findings.push({ severity: 'warning', file: currentFile, line: lineNum, detail: 'Loose equality (`==`) — use strict equality (`===`)' });
3887
+ if (trimmed.match(/\bany\b/) && currentFile.match(/\.ts$/))
3888
+ findings.push({ severity: 'info', file: currentFile, line: lineNum, detail: '`any` type — consider a more specific type' });
3889
+
3890
+ // ── Reliability ──
3891
+ if (trimmed.match(/fetch\(/) && !trimmed.match(/timeout|signal|AbortController/))
3892
+ findings.push({ severity: 'warning', file: currentFile, line: lineNum, detail: 'fetch() without timeout — could hang indefinitely' });
3893
+ if (trimmed.match(/JSON\.parse\(/) && !currentHunk.includes('try'))
3894
+ findings.push({ severity: 'warning', file: currentFile, line: lineNum, detail: 'JSON.parse without try/catch — will throw on invalid input' });
3895
+ if (trimmed.match(/fs\.(readFileSync|writeFileSync)/) && !currentHunk.includes('try'))
3896
+ findings.push({ severity: 'info', file: currentFile, line: lineNum, detail: 'Sync file I/O without error handling' });
3897
+
3898
+ // ── Architecture ──
3899
+ if (trimmed.match(/function\s+\w+/) || trimmed.match(/(const|let)\s+\w+\s*=\s*(async\s+)?\(/)) {
3900
+ const fname = trimmed.match(/function\s+(\w+)/)?.[1] || trimmed.match(/(const|let)\s+(\w+)/)?.[2];
3901
+ if (fname && fileStats[currentFile]) fileStats[currentFile].functions.push(fname);
3902
+ }
3903
+ if (trimmed.match(/^import\s/) || trimmed.match(/require\(/)) {
3904
+ if (fileStats[currentFile]) fileStats[currentFile].imports.push(trimmed.slice(0, 80));
3905
+ }
3906
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
3907
+ removedLines++;
3908
+ lineNum++;
3909
+ } else {
3910
+ lineNum++;
3911
+ }
3912
+ }
3913
+
3914
+ // ── Structural analysis ──
3915
+ const suggestions = [];
3916
+
3917
+ // Large PR
3918
+ if (addedLines > 500)
3919
+ suggestions.push(`Large PR (+${addedLines} lines) — consider splitting into smaller, focused PRs for easier review`);
3920
+ if (files.length > 10)
3921
+ suggestions.push(`${files.length} files changed — verify all changes are related to the PR scope`);
3922
+
3923
+ // Single file dominance
3924
+ const sortedFiles = Object.entries(fileStats).sort((a, b) => b[1].added - a[1].added);
3925
+ if (sortedFiles.length > 1 && sortedFiles[0][1].added > addedLines * 0.8)
3926
+ suggestions.push(`${sortedFiles[0][0]} has ${sortedFiles[0][1].added}/${addedLines} additions — consider if this file is getting too large`);
3927
+
3928
+ // Many new functions
3929
+ const totalNewFunctions = Object.values(fileStats).reduce((sum, s) => sum + s.functions.length, 0);
3930
+ if (totalNewFunctions > 10)
3931
+ suggestions.push(`${totalNewFunctions} new functions added — ensure they're well-tested and documented`);
3932
+
3933
+ // File type analysis
3934
+ const fileTypes = {};
3935
+ for (const f of files) {
3936
+ const ext = f.path?.split('.').pop() || 'unknown';
3937
+ fileTypes[ext] = (fileTypes[ext] || 0) + 1;
3938
+ }
3939
+
3940
+ // Test file check
3941
+ const hasTests = files.some(f => f.path?.match(/test|spec|__tests__/i));
3942
+ if (addedLines > 50 && !hasTests)
3943
+ suggestions.push('No test files included — consider adding tests for new functionality');
3944
+
3945
+ // Deduplicate findings
3946
+ const seen = new Set();
3947
+ const uniqueFindings = findings.filter(f => {
3948
+ const key = f.file + '|' + f.detail;
3949
+ if (seen.has(key)) return false;
3950
+ seen.add(key);
3951
+ return true;
3952
+ });
3953
+
3954
+ // Build assessment
3955
+ const criticalCount = uniqueFindings.filter(f => f.severity === 'critical').length;
3956
+ const highCount = uniqueFindings.filter(f => f.severity === 'high').length;
3957
+ const warningCount = uniqueFindings.filter(f => f.severity === 'warning').length;
3958
+ const infoCount = uniqueFindings.filter(f => f.severity === 'info').length;
3959
+
3960
+ let overallAssessment;
3961
+ if (criticalCount > 0)
3962
+ overallAssessment = `🔴 ${criticalCount} critical issue(s) found — must be addressed before merging`;
3963
+ else if (highCount > 0)
3964
+ overallAssessment = `🟠 ${highCount} high-severity issue(s) — strongly recommend fixing before merge`;
3965
+ else if (warningCount > 3)
3966
+ overallAssessment = `🟡 ${warningCount} warnings — review and address where appropriate`;
3967
+ else if (warningCount > 0)
3968
+ overallAssessment = `🟢 Minor warnings only (${warningCount}) — looks good overall`;
3969
+ else if (infoCount > 0)
3970
+ overallAssessment = `✅ Clean — only informational notes (${infoCount})`;
3971
+ else
3972
+ overallAssessment = `✅ No issues found — LGTM`;
3973
+
3974
+ if (suggestions.length > 0)
3975
+ overallAssessment += '\n\n**Suggestions:**\n' + suggestions.map(s => `- ${s}`).join('\n');
3976
+
3977
+ return {
3978
+ type: 'code-review',
3979
+ summary: `Review of PR: ${prInfo.title}`,
3980
+ author: prInfo.author?.login || 'unknown',
3981
+ filesReviewed: files.length,
3982
+ linesChanged: `+${prInfo.additions || addedLines} / -${prInfo.deletions || removedLines}`,
3983
+ fileTypes,
3984
+ findings: uniqueFindings.slice(0, 30),
3985
+ findingsSummary: { critical: criticalCount, high: highCount, warning: warningCount, info: infoCount },
3986
+ suggestions,
3987
+ overallAssessment,
3988
+ };
3989
+ }
3990
+
3991
+ /** Analyze a code snippet for common issues. */
3992
+ function analyzeCodeSnippet(code, language) {
3993
+ const lines = code.split('\n');
3994
+ const findings = [];
3995
+
3996
+ for (let i = 0; i < lines.length; i++) {
3997
+ const line = lines[i];
3998
+ if (line.includes('console.log'))
3999
+ findings.push({ severity: 'warning', line: i + 1, detail: 'Debug logging' });
4000
+ if (line.includes('TODO') || line.includes('FIXME'))
4001
+ findings.push({ severity: 'info', line: i + 1, detail: line.trim() });
4002
+ if (line.match(/catch\s*\(\s*\w*\s*\)\s*\{\s*\}/))
4003
+ findings.push({ severity: 'warning', line: i + 1, detail: 'Empty catch block' });
4004
+ if (line.includes('eval('))
4005
+ findings.push({ severity: 'critical', line: i + 1, detail: 'eval() is a security risk' });
4006
+ if (line.includes('var '))
4007
+ findings.push({ severity: 'info', line: i + 1, detail: 'Use let/const instead of var' });
4008
+ if (line.includes('password') || line.includes('secret') || line.includes('api_key'))
4009
+ findings.push({ severity: 'critical', line: i + 1, detail: 'Potential secret/credential in code' });
4010
+ if (line.match(/===?\s*null\b/) && !line.match(/!==?\s*null\b/))
4011
+ findings.push({ severity: 'info', line: i + 1, detail: 'Null check — consider optional chaining' });
4012
+ }
4013
+
4014
+ return {
4015
+ type: 'code-review',
4016
+ summary: `Code review (${language}, ${lines.length} lines)`,
4017
+ language,
4018
+ totalLines: lines.length,
4019
+ findings: findings.slice(0, 20),
4020
+ overallAssessment: findings.some(f => f.severity === 'critical')
4021
+ ? 'Critical issues found'
4022
+ : findings.length > 3
4023
+ ? 'Several items to review'
4024
+ : 'Looks reasonable',
4025
+ };
4026
+ }
4027
+
4028
+ /** Handle a tell-joke service request with payment verification. */
4029
+ async function processJokeRequest(msg, identityKey, privKey) {
4030
+ const PRICE = 5;
4031
+
4032
+ // Helper to send rejection
4033
+ async function reject(reason, shortReason) {
4034
+ const rejectPayload = {
4035
+ requestId: msg.id, serviceId: 'tell-joke', status: 'rejected', reason,
4036
+ };
4037
+ const rejectSig = signRelayMessage(privKey, msg.from, 'service-response', rejectPayload);
4038
+ await fetch(`${OVERLAY_URL}/relay/send`, {
4039
+ method: 'POST',
4040
+ headers: { 'Content-Type': 'application/json' },
4041
+ body: JSON.stringify({
4042
+ from: identityKey, to: msg.from, type: 'service-response',
4043
+ payload: rejectPayload, signature: rejectSig,
4044
+ }),
4045
+ });
4046
+ return {
4047
+ id: msg.id, type: 'service-request', serviceId: 'tell-joke',
4048
+ action: 'rejected', reason: shortReason, from: msg.from, ack: true,
4049
+ };
4050
+ }
4051
+
4052
+ // ── Payment verification via shared helper ──
4053
+ const walletIdentity = JSON.parse(fs.readFileSync(path.join(WALLET_DIR, 'wallet-identity.json'), 'utf-8'));
4054
+ const ourHash160 = Hash.hash160(PrivateKey.fromHex(walletIdentity.rootKeyHex).toPublicKey().encode(true));
4055
+ const payResult = await verifyAndAcceptPayment(msg.payload?.payment, PRICE, msg.from, 'tell-joke', ourHash160);
4056
+ if (!payResult.accepted) {
4057
+ return reject(`Payment rejected: ${payResult.error}. This service costs ${PRICE} sats.`, payResult.error);
4058
+ }
4059
+
4060
+ const paymentTxid = payResult.txid;
4061
+ const paymentSats = payResult.satoshis;
4062
+ const walletAccepted = payResult.walletAccepted;
4063
+ const acceptError = payResult.error;
4064
+
4065
+ // Serve the joke
4066
+ const joke = JOKES[Math.floor(Math.random() * JOKES.length)];
4067
+ const responsePayload = {
4068
+ requestId: msg.id, serviceId: 'tell-joke', status: 'fulfilled',
4069
+ result: joke, paymentAccepted: true, paymentTxid,
4070
+ satoshisReceived: paymentSats, walletAccepted,
4071
+ ...(acceptError ? { walletError: acceptError } : {}),
4072
+ };
4073
+ const respSig = signRelayMessage(privKey, msg.from, 'service-response', responsePayload);
4074
+ await fetch(`${OVERLAY_URL}/relay/send`, {
4075
+ method: 'POST',
4076
+ headers: { 'Content-Type': 'application/json' },
4077
+ body: JSON.stringify({
4078
+ from: identityKey, to: msg.from, type: 'service-response',
4079
+ payload: responsePayload, signature: respSig,
4080
+ }),
4081
+ });
4082
+
4083
+ return {
4084
+ id: msg.id, type: 'service-request', serviceId: 'tell-joke',
4085
+ action: 'fulfilled', joke, paymentAccepted: true, paymentTxid,
4086
+ satoshisReceived: paymentSats, walletAccepted,
4087
+ direction: 'incoming-request', // We received a request and earned sats
4088
+ formatted: {
4089
+ type: 'joke-fulfilled',
4090
+ summary: `Joke served: "${joke.setup}" — "${joke.punchline}"`,
4091
+ earnings: paymentSats,
4092
+ },
4093
+ ...(acceptError ? { walletError: acceptError } : {}),
4094
+ from: msg.from, ack: true,
4095
+ };
4096
+ }
4097
+
4098
+ // ---------------------------------------------------------------------------
4099
+ // Poll command — uses shared processMessage
4100
+ // ---------------------------------------------------------------------------
4101
+
4102
+ async function cmdPoll() {
4103
+ const { identityKey, privKey } = loadIdentity();
4104
+
4105
+ // Fetch inbox
4106
+ const inboxResp = await fetch(`${OVERLAY_URL}/relay/inbox?identity=${identityKey}`);
4107
+ if (!inboxResp.ok) {
4108
+ const body = await inboxResp.text();
4109
+ return fail(`Relay inbox failed (${inboxResp.status}): ${body}`);
4110
+ }
4111
+ const inbox = await inboxResp.json();
4112
+
4113
+ if (inbox.count === 0) {
4114
+ return ok({ processed: 0, messages: [], summary: 'No pending messages.' });
4115
+ }
4116
+
4117
+ const processed = [];
4118
+ const ackedIds = [];
4119
+ const unhandled = [];
4120
+
4121
+ for (const msg of inbox.messages) {
4122
+ const result = await processMessage(msg, identityKey, privKey);
4123
+ if (result.ack) {
4124
+ ackedIds.push(result.id);
4125
+ processed.push(result);
4126
+ } else {
4127
+ unhandled.push(result);
4128
+ }
4129
+ }
4130
+
4131
+ // ACK processed messages
4132
+ if (ackedIds.length > 0) {
4133
+ await fetch(`${OVERLAY_URL}/relay/ack`, {
4134
+ method: 'POST',
4135
+ headers: { 'Content-Type': 'application/json' },
4136
+ body: JSON.stringify({ identity: identityKey, messageIds: ackedIds }),
4137
+ });
4138
+ }
4139
+
4140
+ ok({
4141
+ processed: processed.length,
4142
+ unhandled: unhandled.length,
4143
+ total: inbox.count,
4144
+ messages: processed,
4145
+ unhandledMessages: unhandled,
4146
+ ackedIds,
4147
+ });
4148
+ }
4149
+
4150
+ // ---------------------------------------------------------------------------
4151
+ // Connect command — WebSocket real-time message processing
4152
+ // ---------------------------------------------------------------------------
4153
+
4154
+ async function cmdConnect() {
4155
+ let WebSocketClient;
4156
+ try {
4157
+ const ws = await import('ws');
4158
+ WebSocketClient = ws.default || ws.WebSocket || ws;
4159
+ } catch {
4160
+ return fail('WebSocket client not available. Install it: npm install ws (in the skill directory)');
4161
+ }
4162
+
4163
+ const { identityKey, privKey } = loadIdentity();
4164
+ const wsUrl = OVERLAY_URL.replace(/^http/, 'ws') + '/relay/subscribe?identity=' + identityKey;
4165
+
4166
+ let reconnectDelay = 1000;
4167
+ let shouldReconnect = true;
4168
+ let currentWs = null;
4169
+
4170
+ function shutdown() {
4171
+ shouldReconnect = false;
4172
+ if (currentWs) {
4173
+ try { currentWs.close(); } catch {}
4174
+ }
4175
+ process.exit(0);
4176
+ }
4177
+ process.on('SIGINT', shutdown);
4178
+ process.on('SIGTERM', shutdown);
4179
+
4180
+ function connect() {
4181
+ const ws = new WebSocketClient(wsUrl);
4182
+ currentWs = ws;
4183
+
4184
+ ws.on('open', () => {
4185
+ reconnectDelay = 1000; // reset on successful connect
4186
+ console.error(JSON.stringify({ event: 'connected', identity: identityKey, overlay: OVERLAY_URL }));
4187
+ });
4188
+
4189
+ ws.on('message', async (data) => {
4190
+ try {
4191
+ const envelope = JSON.parse(data.toString());
4192
+ if (envelope.type === 'message') {
4193
+ const result = await processMessage(envelope.message, identityKey, privKey);
4194
+ // Output the result as a JSON line to stdout
4195
+ console.log(JSON.stringify(result));
4196
+
4197
+ // Also append to notification log for external consumers (cron, etc.)
4198
+ const notifPath = path.join(OVERLAY_STATE_DIR, 'notifications.jsonl');
4199
+ try {
4200
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
4201
+ fs.appendFileSync(notifPath, JSON.stringify({ ...result, _ts: Date.now() }) + '\n');
4202
+ } catch {}
4203
+ // Ack the message
4204
+ if (result.ack) {
4205
+ try {
4206
+ await fetch(OVERLAY_URL + '/relay/ack', {
4207
+ method: 'POST',
4208
+ headers: { 'Content-Type': 'application/json' },
4209
+ body: JSON.stringify({ identity: identityKey, messageIds: [result.id] }),
4210
+ });
4211
+ } catch (ackErr) {
4212
+ console.error(JSON.stringify({ event: 'ack-error', id: result.id, message: String(ackErr) }));
4213
+ }
4214
+ }
4215
+ }
4216
+ // Handle service announcements from the overlay
4217
+ if (envelope.type === 'service-announced') {
4218
+ const svc = envelope.service || {};
4219
+ const announcement = {
4220
+ event: 'service-announced',
4221
+ serviceId: svc.serviceId,
4222
+ name: svc.name,
4223
+ description: svc.description,
4224
+ priceSats: svc.pricingSats,
4225
+ provider: svc.identityKey,
4226
+ txid: envelope.txid,
4227
+ _ts: Date.now(),
4228
+ };
4229
+ console.log(JSON.stringify(announcement));
4230
+ // Also write to notification log
4231
+ const notifPath = path.join(OVERLAY_STATE_DIR, 'notifications.jsonl');
4232
+ try {
4233
+ fs.mkdirSync(OVERLAY_STATE_DIR, { recursive: true });
4234
+ fs.appendFileSync(notifPath, JSON.stringify(announcement) + '\n');
4235
+ } catch {}
4236
+ }
4237
+ // Ignore 'connected' type — just informational
4238
+ } catch (err) {
4239
+ console.error(JSON.stringify({ event: 'process-error', message: String(err) }));
4240
+ }
4241
+ });
4242
+
4243
+ ws.on('close', () => {
4244
+ currentWs = null;
4245
+ if (shouldReconnect) {
4246
+ console.error(JSON.stringify({ event: 'disconnected', reconnectMs: reconnectDelay }));
4247
+ setTimeout(connect, reconnectDelay);
4248
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
4249
+ }
4250
+ });
4251
+
4252
+ ws.on('error', (err) => {
4253
+ console.error(JSON.stringify({ event: 'error', message: err.message }));
4254
+ });
4255
+ }
4256
+
4257
+ connect();
4258
+ // Keep the process alive — never resolves
4259
+ await new Promise(() => {});
4260
+ }
4261
+
4262
+ // ---------------------------------------------------------------------------
4263
+ // research-queue / research-respond — Clawdbot processes web research via its tools
4264
+ // ---------------------------------------------------------------------------
4265
+
4266
+ async function cmdResearchQueue() {
4267
+ const queuePath = path.join(OVERLAY_STATE_DIR, 'research-queue.jsonl');
4268
+ if (!fs.existsSync(queuePath)) return ok({ pending: [] });
4269
+ const lines = fs.readFileSync(queuePath, 'utf-8').trim().split('\n').filter(Boolean);
4270
+ const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
4271
+ ok({ pending: entries, count: entries.length });
4272
+ }
4273
+
4274
+ async function cmdResearchRespond(resultJsonPath) {
4275
+ if (!resultJsonPath) return fail('Usage: research-respond <resultJsonFile>');
4276
+ if (!fs.existsSync(resultJsonPath)) return fail(`File not found: ${resultJsonPath}`);
4277
+
4278
+ const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf-8'));
4279
+ const { requestId, from: recipientKey, query, research } = result;
4280
+
4281
+ if (!requestId || !recipientKey || !research) {
4282
+ return fail('Result JSON must have: requestId, from, query, research');
4283
+ }
4284
+
4285
+ // Load identity
4286
+ const identityPath = path.join(WALLET_DIR, 'wallet-identity.json');
4287
+ if (!fs.existsSync(identityPath)) return fail('Wallet not initialized.');
4288
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
4289
+ const privKey = PrivateKey.fromHex(identity.rootKeyHex);
4290
+ const identityKey = privKey.toPublicKey().toString();
4291
+ const relayPrivHex = identity.relayKeyHex || identity.rootKeyHex;
4292
+ const relayPrivKey = PrivateKey.fromHex(relayPrivHex);
4293
+
4294
+ const responsePayload = {
4295
+ requestId,
4296
+ serviceId: 'web-research',
4297
+ status: 'fulfilled',
4298
+ result: research,
4299
+ paymentAccepted: true,
4300
+ paymentTxid: result.paymentTxid || null,
4301
+ satoshisReceived: result.satoshisReceived || 0,
4302
+ walletAccepted: result.walletAccepted ?? true,
4303
+ };
4304
+
4305
+ const sig = signRelayMessage(relayPrivKey, recipientKey, 'service-response', responsePayload);
4306
+ const sendResp = await fetch(`${OVERLAY_URL}/relay/send`, {
4307
+ method: 'POST',
4308
+ headers: { 'Content-Type': 'application/json' },
4309
+ body: JSON.stringify({
4310
+ from: identityKey,
4311
+ to: recipientKey,
4312
+ type: 'service-response',
4313
+ payload: responsePayload,
4314
+ signature: sig,
4315
+ }),
4316
+ });
4317
+
4318
+ if (!sendResp.ok) {
4319
+ return fail(`Failed to send response: ${await sendResp.text()}`);
4320
+ }
4321
+
4322
+ const sendResult = await sendResp.json();
4323
+
4324
+ // Remove from queue
4325
+ const queuePath = path.join(OVERLAY_STATE_DIR, 'research-queue.jsonl');
4326
+ if (fs.existsSync(queuePath)) {
4327
+ const lines = fs.readFileSync(queuePath, 'utf-8').trim().split('\n').filter(Boolean);
4328
+ const remaining = lines.filter(l => {
4329
+ try { return JSON.parse(l).requestId !== requestId; } catch { return true; }
4330
+ });
4331
+ fs.writeFileSync(queuePath, remaining.length ? remaining.join('\n') + '\n' : '');
4332
+ }
4333
+
4334
+ ok({ responded: true, requestId, to: recipientKey, query, pushed: sendResult.pushed });
4335
+ }
4336
+
4337
+ // ---------------------------------------------------------------------------
4338
+ // service-queue / respond-service — Agent-routed service fulfillment
4339
+ // ---------------------------------------------------------------------------
4340
+
4341
+ async function cmdServiceQueue() {
4342
+ const queuePath = path.join(OVERLAY_STATE_DIR, 'service-queue.jsonl');
4343
+ if (!fs.existsSync(queuePath)) return ok({ pending: [], count: 0 });
4344
+ const lines = fs.readFileSync(queuePath, 'utf-8').trim().split('\n').filter(Boolean);
4345
+ const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
4346
+ const pending = entries.filter(e => e.status === 'pending');
4347
+ ok({ pending, count: pending.length, total: entries.length });
4348
+ }
4349
+
4350
+ async function cmdRespondService(requestId, recipientKey, serviceId, resultJson) {
4351
+ if (!requestId || !recipientKey || !serviceId || !resultJson) {
4352
+ return fail('Usage: respond-service <requestId> <recipientKey> <serviceId> <resultJson>');
4353
+ }
4354
+
4355
+ let result;
4356
+ try {
4357
+ result = JSON.parse(resultJson);
4358
+ } catch {
4359
+ return fail('resultJson must be valid JSON');
4360
+ }
4361
+
4362
+ const { identityKey, privKey } = loadIdentity();
4363
+
4364
+ const responsePayload = {
4365
+ requestId,
4366
+ serviceId,
4367
+ status: 'fulfilled',
4368
+ result,
4369
+ };
4370
+
4371
+ const sig = signRelayMessage(privKey, recipientKey, 'service-response', responsePayload);
4372
+ const resp = await fetch(`${OVERLAY_URL}/relay/send`, {
4373
+ method: 'POST',
4374
+ headers: { 'Content-Type': 'application/json' },
4375
+ body: JSON.stringify({
4376
+ from: identityKey,
4377
+ to: recipientKey,
4378
+ type: 'service-response',
4379
+ payload: responsePayload,
4380
+ signature: sig,
4381
+ }),
4382
+ });
4383
+
4384
+ if (!resp.ok) return fail(`Relay send failed: ${resp.status}`);
4385
+
4386
+ // Mark as fulfilled in queue
4387
+ const queuePath = path.join(OVERLAY_STATE_DIR, 'service-queue.jsonl');
4388
+ if (fs.existsSync(queuePath)) {
4389
+ const lines = fs.readFileSync(queuePath, 'utf-8').trim().split('\n').filter(Boolean);
4390
+ const updated = lines.map(line => {
4391
+ try {
4392
+ const entry = JSON.parse(line);
4393
+ if (entry.requestId === requestId) {
4394
+ return JSON.stringify({ ...entry, status: 'fulfilled', fulfilledAt: Date.now() });
4395
+ }
4396
+ return line;
4397
+ } catch { return line; }
4398
+ });
4399
+ fs.writeFileSync(queuePath, updated.join('\n') + '\n');
4400
+ }
4401
+
4402
+ ok({ sent: true, requestId, serviceId, to: recipientKey });
4403
+ }
4404
+
4405
+ async function cmdRequestService(targetKey, serviceId, satsStr, inputJsonStr) {
4406
+ if (!targetKey || !serviceId) {
4407
+ return fail('Usage: request-service <identityKey> <serviceId> [sats] [inputJson]');
4408
+ }
4409
+ if (!/^0[23][0-9a-fA-F]{64}$/.test(targetKey)) {
4410
+ return fail('Target must be a compressed public key (66 hex chars, 02/03 prefix)');
4411
+ }
4412
+
4413
+ const { identityKey, privKey } = loadIdentity();
4414
+ const sats = parseInt(satsStr || '5', 10);
4415
+
4416
+ // Parse optional input JSON
4417
+ let inputData = null;
4418
+ if (inputJsonStr) {
4419
+ try {
4420
+ inputData = JSON.parse(inputJsonStr);
4421
+ } catch {
4422
+ return fail('inputJson must be valid JSON');
4423
+ }
4424
+ }
4425
+
4426
+ // Build the service request payload
4427
+ let paymentData = null;
4428
+
4429
+ if (sats > 0) {
4430
+ try {
4431
+ const payment = await buildDirectPayment(targetKey, sats, `service-request: ${serviceId}`);
4432
+ paymentData = {
4433
+ beef: payment.beef,
4434
+ txid: payment.txid,
4435
+ satoshis: payment.satoshis,
4436
+ derivationPrefix: payment.derivationPrefix,
4437
+ derivationSuffix: payment.derivationSuffix,
4438
+ senderIdentityKey: payment.senderIdentityKey,
4439
+ };
4440
+ } catch (err) {
4441
+ // Payment failed — send request without payment
4442
+ paymentData = { error: String(err instanceof Error ? err.message : err) };
4443
+ }
4444
+ }
4445
+
4446
+ const requestPayload = {
4447
+ serviceId,
4448
+ ...(inputData ? { input: inputData } : {}),
4449
+ payment: paymentData,
4450
+ requestedAt: new Date().toISOString(),
4451
+ };
4452
+
4453
+ const signature = signRelayMessage(privKey, targetKey, 'service-request', requestPayload);
4454
+
4455
+ const resp = await fetch(`${OVERLAY_URL}/relay/send`, {
4456
+ method: 'POST',
4457
+ headers: { 'Content-Type': 'application/json' },
4458
+ body: JSON.stringify({
4459
+ from: identityKey,
4460
+ to: targetKey,
4461
+ type: 'service-request',
4462
+ payload: requestPayload,
4463
+ signature,
4464
+ }),
4465
+ });
4466
+ if (!resp.ok) {
4467
+ const body = await resp.text();
4468
+ return fail(`Relay send failed (${resp.status}): ${body}`);
4469
+ }
4470
+ const result = await resp.json();
4471
+
4472
+ ok({
4473
+ sent: true,
4474
+ requestId: result.id,
4475
+ to: targetKey,
4476
+ serviceId,
4477
+ paymentIncluded: paymentData && !paymentData.error,
4478
+ paymentTxid: paymentData?.txid || null,
4479
+ satoshis: paymentData?.satoshis || 0,
4480
+ note: 'Poll for service-response to get the result',
4481
+ });
4482
+ }
4483
+
4484
+ // ---------------------------------------------------------------------------
4485
+ // Main dispatch
4486
+ // ---------------------------------------------------------------------------
4487
+ const [,, command, ...args] = process.argv;
4488
+
4489
+ try {
4490
+ switch (command) {
4491
+ // Wallet
4492
+ case 'setup': await cmdSetup(); break;
4493
+ case 'identity': await cmdIdentity(); break;
4494
+ case 'address': await cmdAddress(); break;
4495
+ case 'balance': await cmdBalance(); break;
4496
+ case 'import': await cmdImport(args[0], args[1]); break;
4497
+ case 'refund': await cmdRefund(args[0]); break;
4498
+
4499
+ // Overlay registration
4500
+ case 'register': await cmdRegister(); break;
4501
+ case 'unregister': await cmdUnregister(); break;
4502
+
4503
+ // Services
4504
+ case 'services': await cmdServices(); break;
4505
+ case 'advertise': await cmdAdvertise(args[0], args[1], args[2], args[3]); break;
4506
+ case 'remove': await cmdRemove(args[0]); break;
4507
+ case 'readvertise': await cmdReadvertise(args[0], args[1], args[2], args.slice(3).join(' ') || undefined); break;
4508
+
4509
+ // Discovery
4510
+ case 'discover': await cmdDiscover(args); break;
4511
+
4512
+ // Payments
4513
+ case 'pay': await cmdPay(args[0], args[1], args.slice(2).join(' ') || undefined); break;
4514
+ case 'verify': await cmdVerify(args[0]); break;
4515
+ case 'accept': await cmdAccept(args[0], args[1], args[2], args[3], args.slice(4).join(' ') || undefined); break;
4516
+
4517
+ // Messaging (relay)
4518
+ case 'send': await cmdSend(args[0], args[1], args[2]); break;
4519
+ case 'inbox': await cmdInbox(args); break;
4520
+ case 'ack': await cmdAck(args); break;
4521
+ case 'poll': await cmdPoll(); break;
4522
+ case 'connect': await cmdConnect(); break;
4523
+ case 'request-service': await cmdRequestService(args[0], args[1], args[2], args[3]); break;
4524
+ case 'research-respond': await cmdResearchRespond(args[0]); break;
4525
+ case 'research-queue': await cmdResearchQueue(); break;
4526
+ case 'service-queue': await cmdServiceQueue(); break;
4527
+ case 'respond-service': await cmdRespondService(args[0], args[1], args[2], args.slice(3).join(' ')); break;
4528
+
4529
+ default:
4530
+ fail(`Unknown command: ${command || '(none)'}. Commands: setup, identity, address, balance, import, refund, register, unregister, ` +
4531
+ `services, advertise, readvertise, remove, discover, pay, verify, accept, send, inbox, ack, poll, connect, ` +
4532
+ `request-service, research-queue, research-respond, service-queue, respond-service`);
4533
+ }
4534
+ } catch (err) {
4535
+ fail(err instanceof Error ? err.message : String(err));
4536
+ }