@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.
- package/README.md +339 -0
- package/SKILL.md +327 -0
- package/clawdbot.plugin.json +55 -0
- package/index.ts +1062 -0
- package/package.json +47 -0
- package/scripts/overlay-cli.mjs +4536 -0
- package/scripts/setup.sh +96 -0
|
@@ -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
|
+
}
|