@fibscope/agent 0.1.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/.env.example +7 -0
- package/PUBLISH.md +27 -0
- package/README.md +123 -0
- package/index.mjs +3244 -0
- package/lib/bridge/recommendation.mjs +311 -0
- package/lib/fnn-discovery.mjs +293 -0
- package/lib/observe/collector.mjs +1039 -0
- package/lib/pulse/engine.mjs +21 -0
- package/lib/pulse/index.mjs +6 -0
- package/lib/pulse/intelligence.mjs +154 -0
- package/lib/pulse/providers.mjs +321 -0
- package/lib/pulse/server.mjs +54 -0
- package/lib/pulse/snapshot.mjs +155 -0
- package/lib/route/route.mjs +1053 -0
- package/package.json +29 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,3244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import {
|
|
11
|
+
addressToScript,
|
|
12
|
+
blake160,
|
|
13
|
+
blake2b,
|
|
14
|
+
bytesToHex,
|
|
15
|
+
calculateTransactionFee,
|
|
16
|
+
ECPair,
|
|
17
|
+
EMPTY_SECP_SIG,
|
|
18
|
+
getTransactionSize,
|
|
19
|
+
hexToBytes,
|
|
20
|
+
PERSONAL,
|
|
21
|
+
privateKeyToAddress,
|
|
22
|
+
privateKeyToPublicKey,
|
|
23
|
+
rawTransactionToHash,
|
|
24
|
+
serializeWitnessArgs,
|
|
25
|
+
scriptToAddress,
|
|
26
|
+
systemScripts,
|
|
27
|
+
toUint64Le,
|
|
28
|
+
} from "@nervosnetwork/ckb-sdk-utils";
|
|
29
|
+
import { collectFiberStateReport } from "./lib/pulse/engine.mjs";
|
|
30
|
+
import { createTelemetryCollector } from "./lib/observe/collector.mjs";
|
|
31
|
+
import { recommend } from "./lib/bridge/recommendation.mjs";
|
|
32
|
+
import { virtualizePayment } from "./lib/route/route.mjs";
|
|
33
|
+
import { chooseFnnInstance, describeFnnInstance, discoverFnnInstances, rpcResponds } from "./lib/fnn-discovery.mjs";
|
|
34
|
+
|
|
35
|
+
const AGENT_VERSION = "0.1.0";
|
|
36
|
+
const DEFAULT_CONFIG_PATH = ".fiber/agent.json";
|
|
37
|
+
const DEFAULT_STATUS_PATH = ".fiber/agent-status.json";
|
|
38
|
+
const DEFAULT_PUSH_INTERVAL_MS = 15_000;
|
|
39
|
+
const MAX_BACKOFF_MS = 300_000;
|
|
40
|
+
const DEFAULT_PULSE_MODULE = new URL("./lib/pulse/index.mjs", import.meta.url).href;
|
|
41
|
+
const DEFAULT_FNN_PREFLIGHT_TIMEOUT_MS = 2_500;
|
|
42
|
+
const DEFAULT_REPORT_URL = "https://ejchwtvdxpyojxsursmh.supabase.co/functions/v1/fibscope-gateway/agent/v1/report";
|
|
43
|
+
const AGENT_ENV_PATHS = [resolve("agent/.env"), resolve(".env")];
|
|
44
|
+
const ENV_REPORT_URL = "FIBGATE";
|
|
45
|
+
const ENV_REPORT_URL_ALIAS = "FIBSCOPE_AGENT_REPORT_URL";
|
|
46
|
+
const ENV_AGENT_TOKEN = "FIBKEY";
|
|
47
|
+
const ENV_AGENT_TOKEN_ALIAS = "FIBSCOPE_AGENT_TOKEN";
|
|
48
|
+
const ENV_CKB_RPC_URL = "FIBCKB";
|
|
49
|
+
const ENV_WALLET_PATH = "FIBWALLET";
|
|
50
|
+
const DEFAULT_COMMAND_POLL_MS = 5_000;
|
|
51
|
+
const DEFAULT_KNOWN_PEER_RECONNECT_MS = 60_000;
|
|
52
|
+
const DEFAULT_CHANNEL_FUNDING_CKB = "499";
|
|
53
|
+
const DEFAULT_CHANNEL_FUNDING_RESERVE_SHANNONS = 100_000n;
|
|
54
|
+
const DEFAULT_CHANNEL_OPEN_TIMEOUT_MS = 60_000;
|
|
55
|
+
const DEFAULT_CHANNEL_OPEN_RETRY_MS = 2_000;
|
|
56
|
+
const DEFAULT_CHANNEL_FAILURE_WATCH_MS = 45_000;
|
|
57
|
+
const DEFAULT_CHANNEL_FAILURE_POLL_MS = 3_000;
|
|
58
|
+
const MIN_SECP_CELL_CAPACITY = 61n * 100_000_000n;
|
|
59
|
+
const DEFAULT_PAYOUT_FEE_RATE = 1_000n;
|
|
60
|
+
const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
|
|
61
|
+
const SIGHASH_CODE_HASH = "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8";
|
|
62
|
+
const inferredPeerAddressCache = new Map();
|
|
63
|
+
|
|
64
|
+
loadAgentEnv();
|
|
65
|
+
|
|
66
|
+
export async function init(options = {}) {
|
|
67
|
+
const configPath = resolve(options.config ?? DEFAULT_CONFIG_PATH);
|
|
68
|
+
const existing = existsSync(configPath) ? await readJson(configPath) : {};
|
|
69
|
+
const terminal = createInterface({ input, output });
|
|
70
|
+
try {
|
|
71
|
+
const discoveredRpcUrl = await discoverAgentRpcUrl(existing.fiberRpcUrl ?? process.env.FIBSCOPE_AGENT_FIBER_RPC_URL, {
|
|
72
|
+
prompt: (label) => terminal.question(label),
|
|
73
|
+
});
|
|
74
|
+
const reportUrl = resolveReportUrl(existing);
|
|
75
|
+
const reportUrlOverride = configuredReportUrl(existing);
|
|
76
|
+
const agentToken = await prompt(terminal, "Agent token", existing.agentToken ?? envValue(ENV_AGENT_TOKEN, ENV_AGENT_TOKEN_ALIAS) ?? "");
|
|
77
|
+
const chain = await prompt(terminal, "Chain", existing.chain ?? process.env.FIBSCOPE_AGENT_CHAIN ?? "testnet");
|
|
78
|
+
const fiberRpcUrl = await prompt(terminal, "Local Fiber RPC URL", discoveredRpcUrl ?? existing.fiberRpcUrl ?? process.env.FIBSCOPE_AGENT_FIBER_RPC_URL ?? "http://127.0.0.1:8227");
|
|
79
|
+
const pushIntervalMs = Number(await prompt(terminal, "Push interval ms", String(existing.pushIntervalMs ?? process.env.FIBSCOPE_AGENT_PUSH_INTERVAL_MS ?? DEFAULT_PUSH_INTERVAL_MS)));
|
|
80
|
+
const commandPollMs = Number(await prompt(terminal, "Command poll ms", String(existing.commandPollMs ?? process.env.FIBSCOPE_AGENT_COMMAND_POLL_MS ?? DEFAULT_COMMAND_POLL_MS)));
|
|
81
|
+
const publicProfileDefault = (existing.publicProfileEnabled ?? yes(process.env.FIBSCOPE_AGENT_PUBLIC_PROFILE)) ? "y" : "n";
|
|
82
|
+
const publicProfileEnabled = yes(await prompt(terminal, "Enable public profile? (y/n)", publicProfileDefault));
|
|
83
|
+
const overwriteNodeDefault = (existing.overwriteNodeRegistration ?? yes(process.env.FIBSCOPE_AGENT_OVERWRITE_NODE_REGISTRATION)) ? "y" : "n";
|
|
84
|
+
const overwriteNodeRegistration = yes(await prompt(terminal, "Allow this API key to replace a previously registered node? (y/n)", overwriteNodeDefault));
|
|
85
|
+
const config = {
|
|
86
|
+
...(reportUrlOverride ? { reportUrl } : {}),
|
|
87
|
+
agentToken,
|
|
88
|
+
chain,
|
|
89
|
+
fiberRpcUrl,
|
|
90
|
+
pushIntervalMs: Number.isFinite(pushIntervalMs) && pushIntervalMs > 0 ? pushIntervalMs : DEFAULT_PUSH_INTERVAL_MS,
|
|
91
|
+
commandPollMs: Number.isFinite(commandPollMs) && commandPollMs > 0 ? commandPollMs : DEFAULT_COMMAND_POLL_MS,
|
|
92
|
+
publicProfileEnabled,
|
|
93
|
+
overwriteNodeRegistration,
|
|
94
|
+
publicFields: existing.publicFields ?? {
|
|
95
|
+
node: true,
|
|
96
|
+
peers: true,
|
|
97
|
+
channels: true,
|
|
98
|
+
liquidity: true,
|
|
99
|
+
diagnostics: true,
|
|
100
|
+
health: true,
|
|
101
|
+
metrics: true,
|
|
102
|
+
alerts: true,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
await writeJson(configPath, await enrichConfigFromMatchingFnn(config));
|
|
106
|
+
console.log(`Agent config written to ${configPath}`);
|
|
107
|
+
console.log(`Fibscope endpoint: ${reportUrlOverride ? reportUrl : "built in"}`);
|
|
108
|
+
return config;
|
|
109
|
+
} finally {
|
|
110
|
+
terminal.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function once(options = {}) {
|
|
115
|
+
const runtime = await createRuntime(options);
|
|
116
|
+
await runtime.rehydrateKnownPeers({ force: true }).catch(() => undefined);
|
|
117
|
+
const report = await runtime.collect();
|
|
118
|
+
let registration;
|
|
119
|
+
try {
|
|
120
|
+
registration = await runtime.register(report);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await runtime.writeStatus({
|
|
123
|
+
running: false,
|
|
124
|
+
remoteConfig: runtime.remoteConfig,
|
|
125
|
+
lastCollectionAt: report.collectedAt,
|
|
126
|
+
lastRegistrationStatus: "failed",
|
|
127
|
+
lastRegistrationError: message(error),
|
|
128
|
+
retainedReport: true,
|
|
129
|
+
});
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
const result = await pushWithRegistrationRetry(runtime, report);
|
|
133
|
+
await runtime.writeStatus({
|
|
134
|
+
running: false,
|
|
135
|
+
remoteConfig: runtime.remoteConfig,
|
|
136
|
+
lastCollectionAt: report.collectedAt,
|
|
137
|
+
lastRegistrationAt: registration?.node?.lastRegisteredAt ?? Date.now(),
|
|
138
|
+
lastRegistrationStatus: "ok",
|
|
139
|
+
lastRegistrationError: undefined,
|
|
140
|
+
lastRegisteredNodeId: registration?.node?.nodeId,
|
|
141
|
+
lastPushAt: result.ok ? Date.now() : undefined,
|
|
142
|
+
lastPushStatus: result.ok ? "ok" : result.code === "NODE_REGISTRATION_REQUIRED" ? "registration_required" : "failed",
|
|
143
|
+
lastPushError: result.ok ? undefined : result.error,
|
|
144
|
+
retainedReport: result.ok ? false : true,
|
|
145
|
+
});
|
|
146
|
+
if (!result.ok) {
|
|
147
|
+
throw new Error(`Agent push failed: ${result.error}`);
|
|
148
|
+
}
|
|
149
|
+
console.log(JSON.stringify({ ok: true, collectedAt: report.collectedAt, status: result.status }, null, 2));
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function start(options = {}) {
|
|
154
|
+
const runtime = await createRuntime(options);
|
|
155
|
+
let retainedReport = undefined;
|
|
156
|
+
let collectionInFlight = false;
|
|
157
|
+
let transmissionInFlight = false;
|
|
158
|
+
let commandInFlight = false;
|
|
159
|
+
let knownPeerInFlight = false;
|
|
160
|
+
let lastKnownPeerHydrationAt = 0;
|
|
161
|
+
let backoffMs = 1_000;
|
|
162
|
+
let stopped = false;
|
|
163
|
+
let lastCommandPollError;
|
|
164
|
+
|
|
165
|
+
const collectTick = async () => {
|
|
166
|
+
if (collectionInFlight) return;
|
|
167
|
+
collectionInFlight = true;
|
|
168
|
+
try {
|
|
169
|
+
const report = await runtime.collect();
|
|
170
|
+
retainedReport = undefined;
|
|
171
|
+
let registration;
|
|
172
|
+
try {
|
|
173
|
+
registration = await runtime.register(report);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
await runtime.writeStatus({
|
|
176
|
+
running: true,
|
|
177
|
+
remoteConfig: runtime.remoteConfig,
|
|
178
|
+
lastCollectionAt: report.collectedAt,
|
|
179
|
+
lastRegistrationStatus: "failed",
|
|
180
|
+
lastRegistrationError: message(error),
|
|
181
|
+
retainedReport: false,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
retainedReport = report;
|
|
186
|
+
await runtime.writeStatus({
|
|
187
|
+
running: true,
|
|
188
|
+
remoteConfig: runtime.remoteConfig,
|
|
189
|
+
lastCollectionAt: retainedReport.collectedAt,
|
|
190
|
+
lastRegistrationAt: registration?.node?.lastRegisteredAt ?? Date.now(),
|
|
191
|
+
lastRegistrationStatus: "ok",
|
|
192
|
+
lastRegistrationError: undefined,
|
|
193
|
+
lastRegisteredNodeId: registration?.node?.nodeId,
|
|
194
|
+
retainedReport: true,
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
await runtime.writeStatus({
|
|
198
|
+
running: true,
|
|
199
|
+
lastCollectionError: message(error),
|
|
200
|
+
});
|
|
201
|
+
} finally {
|
|
202
|
+
collectionInFlight = false;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const transmitTick = async () => {
|
|
207
|
+
if (transmissionInFlight || !retainedReport) return;
|
|
208
|
+
transmissionInFlight = true;
|
|
209
|
+
const report = retainedReport;
|
|
210
|
+
try {
|
|
211
|
+
const result = await pushWithRegistrationRetry(runtime, report);
|
|
212
|
+
if (result.ok) {
|
|
213
|
+
if (retainedReport === report) retainedReport = undefined;
|
|
214
|
+
backoffMs = 1_000;
|
|
215
|
+
await runtime.writeStatus({
|
|
216
|
+
running: true,
|
|
217
|
+
lastPushAt: Date.now(),
|
|
218
|
+
lastPushStatus: "ok",
|
|
219
|
+
lastPushError: undefined,
|
|
220
|
+
retainedReport: Boolean(retainedReport),
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
backoffMs = nextBackoff(backoffMs);
|
|
224
|
+
await runtime.writeStatus({
|
|
225
|
+
running: true,
|
|
226
|
+
lastPushStatus: result.code === "NODE_REGISTRATION_REQUIRED" ? "registration_required" : result.status === 409 ? "stale" : "failed",
|
|
227
|
+
lastPushError: result.error,
|
|
228
|
+
nextRetryMs: backoffMs,
|
|
229
|
+
retainedReport: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
transmissionInFlight = false;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const commandTick = async () => {
|
|
238
|
+
if (commandInFlight) return;
|
|
239
|
+
commandInFlight = true;
|
|
240
|
+
try {
|
|
241
|
+
const batch = await fetchAgentCommands(runtime.config);
|
|
242
|
+
const results = [];
|
|
243
|
+
for (const command of batch.commands ?? []) {
|
|
244
|
+
results.push(await executeAndReportCommand(runtime, command));
|
|
245
|
+
}
|
|
246
|
+
if (lastCommandPollError) {
|
|
247
|
+
console.log(formatCliBlock({
|
|
248
|
+
title: "Agent commands",
|
|
249
|
+
status: "polling restored",
|
|
250
|
+
tone: "success",
|
|
251
|
+
rows: [
|
|
252
|
+
["Fibscope", runtime.config.reportUrl.replace(/\/report\/?$/, "/commands")],
|
|
253
|
+
],
|
|
254
|
+
}));
|
|
255
|
+
lastCommandPollError = undefined;
|
|
256
|
+
}
|
|
257
|
+
const failed = results.find((result) => result.status === "failed");
|
|
258
|
+
await runtime.writeStatus({
|
|
259
|
+
running: true,
|
|
260
|
+
lastCommandPollAt: Date.now(),
|
|
261
|
+
lastCommandStatus: failed ? "failed" : "ok",
|
|
262
|
+
lastCommandError: failed?.error,
|
|
263
|
+
lastCommandResults: results.length ? results.slice(-10) : undefined,
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const detail = message(error);
|
|
267
|
+
if (detail !== lastCommandPollError) {
|
|
268
|
+
console.warn(formatCliBlock({
|
|
269
|
+
title: "Agent commands",
|
|
270
|
+
status: "poll failed",
|
|
271
|
+
tone: "error",
|
|
272
|
+
rows: [
|
|
273
|
+
["Fibscope", runtime.config.reportUrl.replace(/\/report\/?$/, "/commands")],
|
|
274
|
+
["Result", detail],
|
|
275
|
+
],
|
|
276
|
+
}));
|
|
277
|
+
lastCommandPollError = detail;
|
|
278
|
+
}
|
|
279
|
+
await runtime.writeStatus({
|
|
280
|
+
running: true,
|
|
281
|
+
lastCommandPollAt: Date.now(),
|
|
282
|
+
lastCommandStatus: "failed",
|
|
283
|
+
lastCommandError: detail,
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
commandInFlight = false;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const knownPeerTick = async (force = false) => {
|
|
291
|
+
if (knownPeerInFlight) return;
|
|
292
|
+
if (!force && Date.now() - lastKnownPeerHydrationAt < runtime.config.knownPeerReconnectMs) return;
|
|
293
|
+
knownPeerInFlight = true;
|
|
294
|
+
try {
|
|
295
|
+
const result = await runtime.rehydrateKnownPeers({ force });
|
|
296
|
+
lastKnownPeerHydrationAt = Date.now();
|
|
297
|
+
await runtime.writeStatus({
|
|
298
|
+
running: true,
|
|
299
|
+
lastKnownPeerHydrationAt,
|
|
300
|
+
lastKnownPeerHydrationResult: result,
|
|
301
|
+
lastKnownPeerHydrationError: undefined,
|
|
302
|
+
});
|
|
303
|
+
} catch (error) {
|
|
304
|
+
lastKnownPeerHydrationAt = Date.now();
|
|
305
|
+
await runtime.writeStatus({
|
|
306
|
+
running: true,
|
|
307
|
+
lastKnownPeerHydrationAt,
|
|
308
|
+
lastKnownPeerHydrationError: message(error),
|
|
309
|
+
});
|
|
310
|
+
} finally {
|
|
311
|
+
knownPeerInFlight = false;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const collectionTimer = setInterval(() => void collectTick(), runtime.config.pushIntervalMs);
|
|
316
|
+
const transmissionLoop = async () => {
|
|
317
|
+
while (!stopped) {
|
|
318
|
+
await transmitTick();
|
|
319
|
+
await sleep(retainedReport ? backoffMs : 1_000);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const commandLoop = async () => {
|
|
323
|
+
while (!stopped) {
|
|
324
|
+
await commandTick();
|
|
325
|
+
await sleep(runtime.config.commandPollMs);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const knownPeerLoop = async () => {
|
|
329
|
+
while (!stopped) {
|
|
330
|
+
await knownPeerTick();
|
|
331
|
+
await sleep(Math.max(5_000, runtime.config.knownPeerReconnectMs));
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
await runtime.writeStatus({ running: true, startedAt: Date.now(), retainedReport: false, remoteConfig: runtime.remoteConfig });
|
|
336
|
+
await knownPeerTick(true);
|
|
337
|
+
await collectTick();
|
|
338
|
+
await knownPeerTick(true);
|
|
339
|
+
void transmissionLoop();
|
|
340
|
+
void commandLoop();
|
|
341
|
+
void knownPeerLoop();
|
|
342
|
+
const remoteNode = runtime.remoteConfig?.node?.displayName ?? runtime.remoteConfig?.node?.nodeId;
|
|
343
|
+
console.log(formatAgentStarted(runtime, remoteNode));
|
|
344
|
+
|
|
345
|
+
const stop = async () => {
|
|
346
|
+
if (stopped) return;
|
|
347
|
+
stopped = true;
|
|
348
|
+
clearInterval(collectionTimer);
|
|
349
|
+
await runtime.writeStatus({ running: false, stoppedAt: Date.now(), retainedReport: Boolean(retainedReport) });
|
|
350
|
+
};
|
|
351
|
+
process.once("SIGINT", () => void stop().then(() => process.exit(0)));
|
|
352
|
+
process.once("SIGTERM", () => void stop().then(() => process.exit(0)));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function status(options = {}) {
|
|
356
|
+
const statusPath = resolve(options.status ?? DEFAULT_STATUS_PATH);
|
|
357
|
+
const value = existsSync(statusPath)
|
|
358
|
+
? await readJson(statusPath)
|
|
359
|
+
: { running: false, message: "No agent status file found." };
|
|
360
|
+
console.log(JSON.stringify(value, null, 2));
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function createRuntime(options = {}) {
|
|
365
|
+
const configPath = resolve(options.config ?? DEFAULT_CONFIG_PATH);
|
|
366
|
+
const statusPath = resolve(options.status ?? DEFAULT_STATUS_PATH);
|
|
367
|
+
const config = await loadConfig(configPath, options);
|
|
368
|
+
await ensureFnnPreflight(config, statusPath, options);
|
|
369
|
+
const remoteConfig = await fetchAgentConfig(config).catch(() => undefined);
|
|
370
|
+
if (!config.nodeDisplayName && remoteConfig?.node?.displayName) {
|
|
371
|
+
config.nodeDisplayName = remoteConfig.node.displayName;
|
|
372
|
+
}
|
|
373
|
+
let lastRegisteredFiberNodeId;
|
|
374
|
+
let previousReport;
|
|
375
|
+
const runtime = {
|
|
376
|
+
config,
|
|
377
|
+
remoteConfig,
|
|
378
|
+
configPath,
|
|
379
|
+
statusPath,
|
|
380
|
+
collect: async () => {
|
|
381
|
+
const report = await collectReport(config, previousReport);
|
|
382
|
+
previousReport = report;
|
|
383
|
+
return report;
|
|
384
|
+
},
|
|
385
|
+
register: async (report, options = {}) => {
|
|
386
|
+
const localNodeId = readNodeId(report.node) ?? runtime.remoteConfig?.node?.nodeId;
|
|
387
|
+
const remoteFiberNodeId = runtime.remoteConfig?.node?.fiberNodeId;
|
|
388
|
+
if (!options.force && lastRegisteredFiberNodeId && localNodeId === lastRegisteredFiberNodeId) return runtime.remoteConfig;
|
|
389
|
+
if (!options.force && remoteFiberNodeId && localNodeId === remoteFiberNodeId) return runtime.remoteConfig;
|
|
390
|
+
const registration = await registerAgentNode(config, report, runtime.remoteConfig);
|
|
391
|
+
runtime.remoteConfig = mergeAgentRemoteConfig(runtime.remoteConfig, registration);
|
|
392
|
+
lastRegisteredFiberNodeId = registration?.node?.fiberNodeId ?? localNodeId;
|
|
393
|
+
return registration;
|
|
394
|
+
},
|
|
395
|
+
push: (report, options = {}) => pushReport(config, report, options),
|
|
396
|
+
writeStatus: (patch) => writeStatus(statusPath, patch),
|
|
397
|
+
rehydrateKnownPeers: (options = {}) => reconnectKnownPeersFromGateway(config, runtime.remoteConfig, options),
|
|
398
|
+
};
|
|
399
|
+
return runtime;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function ensureFnnPreflight(config, statusPath, options = {}) {
|
|
403
|
+
if (options.skipFnnPreflight) return;
|
|
404
|
+
const probedAt = Date.now();
|
|
405
|
+
const timeoutMs = positiveNumber(config.fnnPreflightTimeoutMs ?? config.timeoutMs, DEFAULT_FNN_PREFLIGHT_TIMEOUT_MS);
|
|
406
|
+
try {
|
|
407
|
+
const info = await rpcCall(config.fiberRpcUrl, "node_info", [], timeoutMs);
|
|
408
|
+
const nodeId = readNodeId(info);
|
|
409
|
+
const version = textValue(info?.version ?? info?.commit ?? info?.buildVersion ?? info?.build_version);
|
|
410
|
+
if (!nodeId && !version) throw new Error("node_info returned an unexpected Fiber RPC response.");
|
|
411
|
+
await writeStatus(statusPath, {
|
|
412
|
+
running: false,
|
|
413
|
+
fiberRpcUrl: config.fiberRpcUrl,
|
|
414
|
+
fnnStatus: "ready",
|
|
415
|
+
lastFnnProbeAt: probedAt,
|
|
416
|
+
lastFnnProbeError: undefined,
|
|
417
|
+
lastFnnNodeId: nodeId,
|
|
418
|
+
lastFnnVersion: version,
|
|
419
|
+
});
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const detail = message(error);
|
|
422
|
+
await writeStatus(statusPath, {
|
|
423
|
+
running: false,
|
|
424
|
+
fiberRpcUrl: config.fiberRpcUrl,
|
|
425
|
+
fnnStatus: "unreachable",
|
|
426
|
+
lastFnnProbeAt: probedAt,
|
|
427
|
+
lastFnnProbeError: detail,
|
|
428
|
+
});
|
|
429
|
+
throw new Error(formatFnnPreflightError(config.fiberRpcUrl, detail));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function collectReport(config, previousReport = undefined) {
|
|
434
|
+
const engineOptions = {
|
|
435
|
+
rpcUrl: config.fiberRpcUrl,
|
|
436
|
+
timeoutMs: config.timeoutMs ?? 8_000,
|
|
437
|
+
};
|
|
438
|
+
const state = await collectFiberStateReport(engineOptions);
|
|
439
|
+
state.node = await enrichReportNode(state.node, config);
|
|
440
|
+
const funding = await collectFundingState(config);
|
|
441
|
+
const fundingWallet = await collectFundingWalletState(config, funding);
|
|
442
|
+
const collector = createTelemetryCollector({
|
|
443
|
+
...engineOptions,
|
|
444
|
+
pulseModule: DEFAULT_PULSE_MODULE,
|
|
445
|
+
maxSnapshots: 1,
|
|
446
|
+
maxEvents: config.maxEvents ?? 100,
|
|
447
|
+
maxAlerts: config.maxAlerts ?? 50,
|
|
448
|
+
});
|
|
449
|
+
collector.record({
|
|
450
|
+
timestamp: state.timestamp ?? Date.now(),
|
|
451
|
+
node: state.node,
|
|
452
|
+
peers: state.peers ?? [],
|
|
453
|
+
channels: state.channels ?? [],
|
|
454
|
+
payments: state.payments ?? [],
|
|
455
|
+
});
|
|
456
|
+
const observability = {
|
|
457
|
+
health: collector.health(),
|
|
458
|
+
metrics: collector.reliability(),
|
|
459
|
+
alerts: collector.alertLog(),
|
|
460
|
+
};
|
|
461
|
+
const recommendations = recommend({ component1: state, component3: observability });
|
|
462
|
+
const collectedAt = Date.now();
|
|
463
|
+
const progress = buildProgress({
|
|
464
|
+
chain: config.chain ?? "testnet",
|
|
465
|
+
collectedAt,
|
|
466
|
+
state,
|
|
467
|
+
funding,
|
|
468
|
+
observability,
|
|
469
|
+
recommendations,
|
|
470
|
+
});
|
|
471
|
+
const events = deriveProgressEvents(previousReport, {
|
|
472
|
+
collectedAt,
|
|
473
|
+
chain: config.chain ?? "testnet",
|
|
474
|
+
node: state.node,
|
|
475
|
+
progress,
|
|
476
|
+
});
|
|
477
|
+
return {
|
|
478
|
+
agentVersion: AGENT_VERSION,
|
|
479
|
+
chain: config.chain ?? "testnet",
|
|
480
|
+
collectedAt,
|
|
481
|
+
node: state.node,
|
|
482
|
+
peers: state.peers ?? [],
|
|
483
|
+
channels: state.channels ?? [],
|
|
484
|
+
liquidity: state.liquidity ?? {},
|
|
485
|
+
funding,
|
|
486
|
+
fundingWallet,
|
|
487
|
+
diagnostics: state.diagnostics ?? [],
|
|
488
|
+
health: observability.health ?? state.health ?? {},
|
|
489
|
+
metrics: observability.metrics ?? {},
|
|
490
|
+
alerts: observability.alerts ?? [],
|
|
491
|
+
recommendations,
|
|
492
|
+
progress,
|
|
493
|
+
events,
|
|
494
|
+
publicProfileEnabled: Boolean(config.publicProfileEnabled),
|
|
495
|
+
publicFields: config.publicFields ?? {},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function enrichReportNode(node, config) {
|
|
500
|
+
if (!node || typeof node !== "object") return node;
|
|
501
|
+
const displayName = textValue(config.nodeDisplayName);
|
|
502
|
+
const addresses = uniqueStrings([
|
|
503
|
+
...readAddressList(node.addresses),
|
|
504
|
+
...readAddressList(node.address),
|
|
505
|
+
...configuredPeerAddresses(config),
|
|
506
|
+
...await inferPeerAddressesFromLocalFnn(config),
|
|
507
|
+
]);
|
|
508
|
+
if (!addresses.length && !displayName) return node;
|
|
509
|
+
return {
|
|
510
|
+
...node,
|
|
511
|
+
...(displayName ? { displayName } : {}),
|
|
512
|
+
address: node.address ?? addresses[0],
|
|
513
|
+
addresses,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function configuredPeerAddresses(config) {
|
|
518
|
+
return uniqueStrings([
|
|
519
|
+
...readAddressList(config.peerAddresses),
|
|
520
|
+
...readAddressList(config.publicPeerAddresses),
|
|
521
|
+
...readAddressList(config.announcedAddresses),
|
|
522
|
+
...readAddressList(config.peerAddress),
|
|
523
|
+
...readAddressList(config.publicPeerAddress),
|
|
524
|
+
...readAddressList(config.announcedAddress),
|
|
525
|
+
]);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function inferPeerAddressesFromLocalFnn(config) {
|
|
529
|
+
if (!config.fiberRpcUrl) return [];
|
|
530
|
+
const cached = inferredPeerAddressCache.get(config.fiberRpcUrl);
|
|
531
|
+
if (cached && Date.now() - cached.checkedAt < 60_000) return cached.addresses;
|
|
532
|
+
let addresses = [];
|
|
533
|
+
try {
|
|
534
|
+
const instances = await discoverFnnInstances({ timeoutMs: 800 });
|
|
535
|
+
const instance = instances.find((item) => sameRpcUrl(item.rpcUrl, config.fiberRpcUrl));
|
|
536
|
+
addresses = uniqueStrings([
|
|
537
|
+
...readAddressList(instance?.p2pListenAddr).map(normalizeLocalListenAddress),
|
|
538
|
+
]);
|
|
539
|
+
if (instance?.baseDir) {
|
|
540
|
+
addresses = uniqueStrings([
|
|
541
|
+
...addresses,
|
|
542
|
+
...await readPeerAddressesFromFnnLog(join(instance.baseDir, "logs", "fnn.log")),
|
|
543
|
+
]);
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
addresses = [];
|
|
547
|
+
}
|
|
548
|
+
inferredPeerAddressCache.set(config.fiberRpcUrl, { checkedAt: Date.now(), addresses });
|
|
549
|
+
return addresses;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function readPeerAddressesFromFnnLog(logPath) {
|
|
553
|
+
if (!existsSync(logPath)) return [];
|
|
554
|
+
const text = stripAnsi(await readFile(logPath, "utf8"));
|
|
555
|
+
const matches = [...text.matchAll(/"([^"]+\/tcp\/\d+\/p2p\/[^"]+)"/g)];
|
|
556
|
+
const addresses = matches
|
|
557
|
+
.map((match) => match[1])
|
|
558
|
+
.filter((address) => !address.includes("/ws/"))
|
|
559
|
+
.map(normalizeLocalListenAddress);
|
|
560
|
+
return uniqueStrings(addresses).slice(-3);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function normalizeLocalListenAddress(address) {
|
|
564
|
+
return String(address ?? "")
|
|
565
|
+
.trim()
|
|
566
|
+
.replace("/ip4/0.0.0.0/", "/ip4/127.0.0.1/")
|
|
567
|
+
.replace("/ip6/::/", "/ip6/::1/");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function sameRpcUrl(left, right) {
|
|
571
|
+
try {
|
|
572
|
+
const a = new URL(left);
|
|
573
|
+
const b = new URL(right);
|
|
574
|
+
return a.protocol === b.protocol && a.hostname === b.hostname && (a.port || defaultPort(a)) === (b.port || defaultPort(b));
|
|
575
|
+
} catch {
|
|
576
|
+
return left === right;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function defaultPort(url) {
|
|
581
|
+
return url.protocol === "https:" ? "443" : "80";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function collectFundingState(config) {
|
|
585
|
+
const checkedAt = Date.now();
|
|
586
|
+
const indexerUrl = resolveCkbIndexerUrl(config);
|
|
587
|
+
let lockScript;
|
|
588
|
+
let policy;
|
|
589
|
+
try {
|
|
590
|
+
const info = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs ?? 8_000);
|
|
591
|
+
lockScript = normalizeCkbScript(info?.default_funding_lock_script ?? info?.defaultFundingLockScript);
|
|
592
|
+
policy = fundingPolicyFromNodeInfo(info);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
return unavailableFundingState({
|
|
595
|
+
checkedAt,
|
|
596
|
+
indexerUrl,
|
|
597
|
+
error: `FNN funding lock unavailable: ${message(error)}`,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (!lockScript) {
|
|
602
|
+
return unavailableFundingState({
|
|
603
|
+
checkedAt,
|
|
604
|
+
indexerUrl,
|
|
605
|
+
error: "FNN node_info did not include a default funding lock script.",
|
|
606
|
+
policy,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
if (!indexerUrl) {
|
|
610
|
+
return unavailableFundingState({
|
|
611
|
+
checkedAt,
|
|
612
|
+
lockScript,
|
|
613
|
+
error: "No CKB indexer RPC URL is configured. Set FIBCKB or FIBSCOPE_AGENT_CKB_RPC_URL.",
|
|
614
|
+
policy,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const summary = await collectCkbCellsCapacity(indexerUrl, lockScript, config);
|
|
620
|
+
return {
|
|
621
|
+
status: "ready",
|
|
622
|
+
assetType: "CKB",
|
|
623
|
+
source: "ckb-indexer",
|
|
624
|
+
checkedAt,
|
|
625
|
+
ckbRpcUrl: indexerUrl,
|
|
626
|
+
lockScript,
|
|
627
|
+
...summary,
|
|
628
|
+
policy,
|
|
629
|
+
};
|
|
630
|
+
} catch (error) {
|
|
631
|
+
return unavailableFundingState({
|
|
632
|
+
checkedAt,
|
|
633
|
+
indexerUrl,
|
|
634
|
+
lockScript,
|
|
635
|
+
error: `CKB funding balance unavailable: ${message(error)}`,
|
|
636
|
+
policy,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function unavailableFundingState({ checkedAt, indexerUrl, lockScript, error, policy }) {
|
|
642
|
+
return {
|
|
643
|
+
status: "unavailable",
|
|
644
|
+
assetType: "CKB",
|
|
645
|
+
source: indexerUrl ? "ckb-indexer" : "configuration",
|
|
646
|
+
checkedAt,
|
|
647
|
+
ckbRpcUrl: indexerUrl,
|
|
648
|
+
lockScript,
|
|
649
|
+
totalCapacity: "0",
|
|
650
|
+
availableCapacity: "0",
|
|
651
|
+
occupiedCapacity: "0",
|
|
652
|
+
cellCount: 0,
|
|
653
|
+
availableCellCount: 0,
|
|
654
|
+
occupiedCellCount: 0,
|
|
655
|
+
policy,
|
|
656
|
+
error,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function collectFundingWalletState(config, funding = undefined) {
|
|
661
|
+
const checkedAt = Date.now();
|
|
662
|
+
const walletPath = resolveFundingWalletPath(config);
|
|
663
|
+
const keyPath = resolveFundingKeyPath(config, walletPath);
|
|
664
|
+
const indexerUrl = resolveCkbIndexerUrl(config);
|
|
665
|
+
if (!walletPath) {
|
|
666
|
+
return {
|
|
667
|
+
status: "unavailable",
|
|
668
|
+
checkedAt,
|
|
669
|
+
error: "No FNN data directory is known yet. Start or bind the agent to a Fiber node.",
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (!existsSync(walletPath)) {
|
|
673
|
+
return {
|
|
674
|
+
status: "missing",
|
|
675
|
+
checkedAt,
|
|
676
|
+
path: walletPath,
|
|
677
|
+
keyPath,
|
|
678
|
+
restartRequired: false,
|
|
679
|
+
message: "No local funding wallet is linked to this Fiber node.",
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
const wallet = await readFundingWallet(walletPath, config.chain ?? "testnet");
|
|
685
|
+
const fundingLock = normalizeCkbScript(funding?.lockScript);
|
|
686
|
+
const matchesFnnFundingLock = scriptsEqual(wallet.lockScript, fundingLock);
|
|
687
|
+
let balance = {};
|
|
688
|
+
if (matchesFnnFundingLock && funding?.status === "ready") {
|
|
689
|
+
balance = pickFundingBalance(funding);
|
|
690
|
+
} else if (indexerUrl) {
|
|
691
|
+
balance = await collectCkbCellsCapacity(indexerUrl, wallet.lockScript, config);
|
|
692
|
+
}
|
|
693
|
+
const safe = safeFundingWallet(wallet);
|
|
694
|
+
const totalCapacity = amountString(balance.totalCapacity);
|
|
695
|
+
const availableCapacity = amountString(balance.availableCapacity);
|
|
696
|
+
return {
|
|
697
|
+
status: matchesFnnFundingLock ? "linked" : "pending_restart",
|
|
698
|
+
checkedAt,
|
|
699
|
+
path: walletPath,
|
|
700
|
+
keyPath,
|
|
701
|
+
...safe,
|
|
702
|
+
matchesFnnFundingLock,
|
|
703
|
+
restartRequired: !matchesFnnFundingLock,
|
|
704
|
+
balance: {
|
|
705
|
+
...balance,
|
|
706
|
+
totalCapacity,
|
|
707
|
+
availableCapacity,
|
|
708
|
+
occupiedCapacity: amountString(balance.occupiedCapacity),
|
|
709
|
+
},
|
|
710
|
+
replaceRequiresConfirmation: amountBigInt(totalCapacity) > 0n || amountBigInt(availableCapacity) > 0n,
|
|
711
|
+
message: matchesFnnFundingLock
|
|
712
|
+
? "Funding wallet is linked to this Fiber node."
|
|
713
|
+
: "Wallet file exists, but the running Fiber node is still using another key. Restart FNN to activate it.",
|
|
714
|
+
};
|
|
715
|
+
} catch (error) {
|
|
716
|
+
return {
|
|
717
|
+
status: "invalid",
|
|
718
|
+
checkedAt,
|
|
719
|
+
path: walletPath,
|
|
720
|
+
keyPath,
|
|
721
|
+
error: message(error),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function resolveFundingWalletPath(config) {
|
|
727
|
+
const explicit = textValue(config.walletPath ?? process.env.FIBSCOPE_AGENT_WALLET_PATH ?? envValue(ENV_WALLET_PATH));
|
|
728
|
+
if (explicit) return resolve(explicit);
|
|
729
|
+
const baseDir = textValue(config.fnnBaseDir);
|
|
730
|
+
return baseDir ? resolve(baseDir, "ckb", "wallet.json") : "";
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function resolveFundingKeyPath(config, walletPath) {
|
|
734
|
+
const explicit = textValue(config.fiberKeyPath ?? config.keyPath);
|
|
735
|
+
if (explicit) return resolve(explicit);
|
|
736
|
+
return walletPath ? resolve(dirname(walletPath), "key") : "";
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function readFundingWallet(walletPath, fallbackNetwork) {
|
|
740
|
+
const existing = await readJson(walletPath);
|
|
741
|
+
const privateKey = normalizePrivateKey(existing.privateKeyHex ?? existing.private_key ?? existing.fiber?.keyFileValue ?? "");
|
|
742
|
+
const network = normalizeNetwork(existing.network ?? fallbackNetwork);
|
|
743
|
+
return buildFundingWallet(privateKey, network);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function buildFundingWallet(privateKey, networkValue) {
|
|
747
|
+
const network = normalizeNetwork(networkValue);
|
|
748
|
+
const privateKeyHex = `0x${normalizePrivateKey(privateKey)}`;
|
|
749
|
+
const publicKey = privateKeyToPublicKey(privateKeyHex);
|
|
750
|
+
const lockArg = `0x${blake160(publicKey, "hex")}`;
|
|
751
|
+
const sdkLockScript = {
|
|
752
|
+
codeHash: SIGHASH_CODE_HASH,
|
|
753
|
+
hashType: "type",
|
|
754
|
+
args: lockArg,
|
|
755
|
+
};
|
|
756
|
+
const lockScript = {
|
|
757
|
+
code_hash: sdkLockScript.codeHash,
|
|
758
|
+
hash_type: sdkLockScript.hashType,
|
|
759
|
+
args: sdkLockScript.args,
|
|
760
|
+
};
|
|
761
|
+
const addressPrefix = network === "mainnet" ? "ckb" : "ckt";
|
|
762
|
+
return {
|
|
763
|
+
schema: "fiber.wallet.v1",
|
|
764
|
+
network,
|
|
765
|
+
createdAt: new Date().toISOString(),
|
|
766
|
+
privateKeyHex,
|
|
767
|
+
publicKey,
|
|
768
|
+
lockArg,
|
|
769
|
+
address: privateKeyToAddress(privateKeyHex, { prefix: addressPrefix }),
|
|
770
|
+
lockScript,
|
|
771
|
+
sdkLockScript,
|
|
772
|
+
lockScriptAddress: scriptToAddress(sdkLockScript, network === "mainnet"),
|
|
773
|
+
fiber: {
|
|
774
|
+
keyFileFormat: "plain-private-key-hex-without-0x",
|
|
775
|
+
keyFileValue: privateKeyHex.slice(2),
|
|
776
|
+
defaultKeyPath: network === "testnet"
|
|
777
|
+
? ".fiber/testnet-node/ckb/key"
|
|
778
|
+
: ".fiber/mainnet-node/ckb/key",
|
|
779
|
+
},
|
|
780
|
+
faucet: network === "testnet"
|
|
781
|
+
? {
|
|
782
|
+
ckb: "https://faucet.nervos.org",
|
|
783
|
+
note: "Fund this address before opening testnet Fiber channels.",
|
|
784
|
+
}
|
|
785
|
+
: undefined,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function safeFundingWallet(wallet) {
|
|
790
|
+
return {
|
|
791
|
+
schema: wallet.schema,
|
|
792
|
+
network: wallet.network,
|
|
793
|
+
address: wallet.address,
|
|
794
|
+
publicKey: wallet.publicKey,
|
|
795
|
+
lockArg: wallet.lockArg,
|
|
796
|
+
lockScript: wallet.lockScript,
|
|
797
|
+
lockScriptAddress: wallet.lockScriptAddress,
|
|
798
|
+
faucet: wallet.faucet,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function pickFundingBalance(funding) {
|
|
803
|
+
return {
|
|
804
|
+
totalCapacity: amountString(funding?.totalCapacity),
|
|
805
|
+
availableCapacity: amountString(funding?.availableCapacity),
|
|
806
|
+
occupiedCapacity: amountString(funding?.occupiedCapacity),
|
|
807
|
+
cellCount: numeric(funding?.cellCount, 0),
|
|
808
|
+
availableCellCount: numeric(funding?.availableCellCount, 0),
|
|
809
|
+
occupiedCellCount: numeric(funding?.occupiedCellCount, 0),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function scriptsEqual(left, right) {
|
|
814
|
+
const a = normalizeCkbScript(left);
|
|
815
|
+
const b = normalizeCkbScript(right);
|
|
816
|
+
if (!a || !b) return false;
|
|
817
|
+
return a.code_hash?.toLowerCase() === b.code_hash?.toLowerCase()
|
|
818
|
+
&& a.hash_type === b.hash_type
|
|
819
|
+
&& a.args?.toLowerCase() === b.args?.toLowerCase();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function normalizeNetwork(value) {
|
|
823
|
+
const network = String(value ?? "testnet").trim().toLowerCase();
|
|
824
|
+
if (network !== "testnet" && network !== "mainnet") throw new Error("Funding wallet network must be testnet or mainnet.");
|
|
825
|
+
return network;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function normalizePrivateKey(value) {
|
|
829
|
+
const key = String(value ?? "").trim().replace(/^0x/i, "");
|
|
830
|
+
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
831
|
+
throw new Error("Funding wallet private key must be 64 hex characters.");
|
|
832
|
+
}
|
|
833
|
+
const bigint = BigInt(`0x${key}`);
|
|
834
|
+
if (bigint <= 0n || bigint >= SECP256K1_ORDER) {
|
|
835
|
+
throw new Error("Funding wallet private key is outside the secp256k1 private key range.");
|
|
836
|
+
}
|
|
837
|
+
return key.toLowerCase();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function generatePrivateKeyHex() {
|
|
841
|
+
while (true) {
|
|
842
|
+
const key = randomBytes(32).toString("hex");
|
|
843
|
+
const bigint = BigInt(`0x${key}`);
|
|
844
|
+
if (bigint > 0n && bigint < SECP256K1_ORDER) return key;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function writeFundingWallet(config, wallet, options = {}) {
|
|
849
|
+
const walletPath = resolveFundingWalletPath(config);
|
|
850
|
+
const keyPath = resolveFundingKeyPath(config, walletPath);
|
|
851
|
+
if (!walletPath || !keyPath) throw new Error("Funding wallet path is unavailable. Start or bind the agent to a Fiber node first.");
|
|
852
|
+
const current = await collectFundingWalletState(config, await collectFundingState(config));
|
|
853
|
+
assertFundingWalletReplaceAllowed(current, options);
|
|
854
|
+
await mkdir(dirname(walletPath), { recursive: true });
|
|
855
|
+
await writeFile(walletPath, `${JSON.stringify(wallet, null, 2)}\n`, { mode: 0o600 });
|
|
856
|
+
await chmod(walletPath, 0o600).catch(() => undefined);
|
|
857
|
+
await mkdir(dirname(keyPath), { recursive: true });
|
|
858
|
+
await writeFile(keyPath, `${wallet.privateKeyHex.slice(2)}\n`, { mode: 0o600 });
|
|
859
|
+
await chmod(keyPath, 0o600).catch(() => undefined);
|
|
860
|
+
return {
|
|
861
|
+
ok: true,
|
|
862
|
+
path: walletPath,
|
|
863
|
+
keyPath,
|
|
864
|
+
previous: summarizeFundingWalletState(current),
|
|
865
|
+
wallet: safeFundingWallet(wallet),
|
|
866
|
+
restartRequired: true,
|
|
867
|
+
message: "Funding wallet saved. Restart FNN so the node uses this key.",
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function assertFundingWalletReplaceAllowed(current, options) {
|
|
872
|
+
const exists = !["missing", "unavailable"].includes(String(current?.status ?? ""));
|
|
873
|
+
if (exists && !boolValue(options.replace, false)) {
|
|
874
|
+
throw new Error("A funding wallet is already linked. Choose replace to overwrite it.");
|
|
875
|
+
}
|
|
876
|
+
if (walletStateHasBalance(current) && !boolValue(options.confirmReplaceWithBalance, false)) {
|
|
877
|
+
throw new Error(`The existing funding wallet still has ${ckbText(current?.balance?.totalCapacity ?? current?.balance?.availableCapacity)}. Confirm replacement before continuing.`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function walletStateHasBalance(state) {
|
|
882
|
+
const balance = state?.balance && typeof state.balance === "object" ? state.balance : {};
|
|
883
|
+
return amountBigInt(balance.totalCapacity) > 0n || amountBigInt(balance.availableCapacity) > 0n;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function summarizeFundingWalletState(state) {
|
|
887
|
+
if (!state || typeof state !== "object") return undefined;
|
|
888
|
+
return omitUndefined({
|
|
889
|
+
status: state.status,
|
|
890
|
+
address: state.address,
|
|
891
|
+
path: state.path,
|
|
892
|
+
totalCapacity: state.balance?.totalCapacity,
|
|
893
|
+
availableCapacity: state.balance?.availableCapacity,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function createFundingWallet(config, params) {
|
|
898
|
+
const network = normalizeNetwork(params.network ?? config.chain ?? "testnet");
|
|
899
|
+
if (network === "mainnet" && !boolValue(params.allowMainnetGeneration, false)) {
|
|
900
|
+
throw new Error("Creating a new mainnet funding wallet is disabled. Link an existing mainnet wallet instead.");
|
|
901
|
+
}
|
|
902
|
+
const wallet = buildFundingWallet(generatePrivateKeyHex(), network);
|
|
903
|
+
return writeFundingWallet(config, wallet, params);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function linkFundingWallet(config, params) {
|
|
907
|
+
const sourcePath = textValue(params.sourcePath ?? params.path ?? params.walletPath);
|
|
908
|
+
if (!sourcePath) throw new Error("Choose a local wallet.json path for the agent to link.");
|
|
909
|
+
const wallet = await readFundingWallet(resolve(sourcePath), config.chain ?? "testnet");
|
|
910
|
+
return writeFundingWallet(config, wallet, params);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function collectCkbCellsCapacity(indexerUrl, lockScript, config) {
|
|
914
|
+
let totalCapacity = 0n;
|
|
915
|
+
let availableCapacity = 0n;
|
|
916
|
+
let cellCount = 0;
|
|
917
|
+
let availableCellCount = 0;
|
|
918
|
+
let cursor;
|
|
919
|
+
const limit = hexQuantity(positiveNumber(config.ckbCellPageSize, 100));
|
|
920
|
+
const maxPages = Math.max(1, Math.min(50, Math.trunc(positiveNumber(config.ckbCellMaxPages, 20))));
|
|
921
|
+
|
|
922
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
923
|
+
const params = [
|
|
924
|
+
{
|
|
925
|
+
script: lockScript,
|
|
926
|
+
script_type: "lock",
|
|
927
|
+
},
|
|
928
|
+
"asc",
|
|
929
|
+
limit,
|
|
930
|
+
];
|
|
931
|
+
if (cursor) params.push(cursor);
|
|
932
|
+
const result = await rpcCall(indexerUrl, "get_cells", params, config.ckbRpcTimeoutMs ?? config.timeoutMs ?? 10_000);
|
|
933
|
+
const objects = Array.isArray(result?.objects) ? result.objects : [];
|
|
934
|
+
for (const cell of objects) {
|
|
935
|
+
const output = cell?.output ?? cell?.cell_output ?? {};
|
|
936
|
+
const capacity = amountBigInt(output.capacity);
|
|
937
|
+
totalCapacity += capacity;
|
|
938
|
+
cellCount += 1;
|
|
939
|
+
if (!output.type) {
|
|
940
|
+
availableCapacity += capacity;
|
|
941
|
+
availableCellCount += 1;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const nextCursor = typeof result?.last_cursor === "string" && result.last_cursor !== cursor ? result.last_cursor : undefined;
|
|
945
|
+
if (!objects.length || !nextCursor || nextCursor === "0x") break;
|
|
946
|
+
cursor = nextCursor;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const occupiedCapacity = totalCapacity >= availableCapacity ? totalCapacity - availableCapacity : 0n;
|
|
950
|
+
return {
|
|
951
|
+
totalCapacity: totalCapacity.toString(),
|
|
952
|
+
availableCapacity: availableCapacity.toString(),
|
|
953
|
+
occupiedCapacity: occupiedCapacity.toString(),
|
|
954
|
+
cellCount,
|
|
955
|
+
availableCellCount,
|
|
956
|
+
occupiedCellCount: Math.max(0, cellCount - availableCellCount),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function resolveCkbIndexerUrl(config) {
|
|
961
|
+
const explicit = textValue(config.ckbIndexerUrl ?? config.ckbRpcUrl);
|
|
962
|
+
if (explicit) return explicit;
|
|
963
|
+
if (config.chain === "testnet") return "https://testnet.ckbapp.dev/";
|
|
964
|
+
if (config.chain === "mainnet") return "http://127.0.0.1:8114/";
|
|
965
|
+
return "";
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function normalizeCkbScript(value) {
|
|
969
|
+
if (!value || typeof value !== "object") return undefined;
|
|
970
|
+
const codeHash = textValue(value.code_hash ?? value.codeHash);
|
|
971
|
+
const hashType = textValue(value.hash_type ?? value.hashType);
|
|
972
|
+
const args = textValue(value.args);
|
|
973
|
+
if (!codeHash || !hashType || !args) return undefined;
|
|
974
|
+
return {
|
|
975
|
+
code_hash: codeHash,
|
|
976
|
+
hash_type: hashType,
|
|
977
|
+
args,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function fundingPolicyFromNodeInfo(info) {
|
|
982
|
+
if (!info || typeof info !== "object") return undefined;
|
|
983
|
+
return omitUndefined({
|
|
984
|
+
openChannelAutoAcceptMinCkbFundingAmount: amountStringOrUndefined(info.open_channel_auto_accept_min_ckb_funding_amount),
|
|
985
|
+
autoAcceptChannelCkbFundingAmount: amountStringOrUndefined(info.auto_accept_channel_ckb_funding_amount),
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function amountStringOrUndefined(value) {
|
|
990
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
991
|
+
return amountString(value);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function amountBigInt(value) {
|
|
995
|
+
if (typeof value === "bigint") return value > 0n ? value : 0n;
|
|
996
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return BigInt(Math.trunc(value));
|
|
997
|
+
if (typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value);
|
|
998
|
+
if (typeof value === "string" && /^[0-9]+$/.test(value)) return BigInt(value);
|
|
999
|
+
return 0n;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function channelFundingReserve(config) {
|
|
1003
|
+
const explicitShannons = amountBigInt(config.channelFundingReserveShannons ?? config.channelFundingFeeReserveShannons);
|
|
1004
|
+
if (explicitShannons > 0n) return explicitShannons;
|
|
1005
|
+
const explicitCkb = textValue(config.channelFundingReserveCkb ?? config.channelFundingFeeReserveCkb);
|
|
1006
|
+
if (explicitCkb) return ckbToShannons(explicitCkb);
|
|
1007
|
+
return DEFAULT_CHANNEL_FUNDING_RESERVE_SHANNONS;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function hexQuantity(value) {
|
|
1011
|
+
return `0x${BigInt(Math.max(0, Math.trunc(value))).toString(16)}`;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function pushWithRegistrationRetry(runtime, report, options = {}) {
|
|
1015
|
+
let result = await pushWithRateLimitRetry(runtime, report, options);
|
|
1016
|
+
if (result.ok || result.code !== "NODE_REGISTRATION_REQUIRED") return result;
|
|
1017
|
+
try {
|
|
1018
|
+
const registration = await runtime.register(report, { force: true });
|
|
1019
|
+
await runtime.writeStatus({
|
|
1020
|
+
running: true,
|
|
1021
|
+
remoteConfig: runtime.remoteConfig,
|
|
1022
|
+
lastRegistrationAt: registration?.node?.lastRegisteredAt ?? Date.now(),
|
|
1023
|
+
lastRegistrationStatus: "ok",
|
|
1024
|
+
lastRegistrationError: undefined,
|
|
1025
|
+
lastRegisteredNodeId: registration?.node?.nodeId,
|
|
1026
|
+
});
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
return {
|
|
1029
|
+
ok: false,
|
|
1030
|
+
status: 409,
|
|
1031
|
+
code: "NODE_REGISTRATION_REQUIRED",
|
|
1032
|
+
error: message(error),
|
|
1033
|
+
payload: result.payload,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
result = await pushWithRateLimitRetry(runtime, report, options);
|
|
1037
|
+
return result;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function pushWithRateLimitRetry(runtime, report, options = {}) {
|
|
1041
|
+
const maxRetries = options.retryOnRateLimit ? positiveNumber(options.maxRateLimitRetries, 2) : 0;
|
|
1042
|
+
let attempt = 0;
|
|
1043
|
+
let result = await runtime.push(report, options);
|
|
1044
|
+
while (!result.ok && result.status === 429 && attempt < maxRetries) {
|
|
1045
|
+
attempt += 1;
|
|
1046
|
+
const waitMs = Math.max(1_000, result.retryAfterMs ?? 10_000);
|
|
1047
|
+
console.warn(formatCliBlock({
|
|
1048
|
+
title: "Agent report",
|
|
1049
|
+
status: "rate limited",
|
|
1050
|
+
tone: "error",
|
|
1051
|
+
rows: [
|
|
1052
|
+
["Fibscope", runtime.config.reportUrl],
|
|
1053
|
+
["Retry", `${waitMs} ms`],
|
|
1054
|
+
["Attempt", `${attempt}/${maxRetries}`],
|
|
1055
|
+
],
|
|
1056
|
+
}));
|
|
1057
|
+
await sleep(waitMs);
|
|
1058
|
+
result = await runtime.push(report, options);
|
|
1059
|
+
}
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export async function pushReport(config, report, options = {}) {
|
|
1064
|
+
try {
|
|
1065
|
+
const headers = {
|
|
1066
|
+
"content-type": "application/json",
|
|
1067
|
+
"x-agent-token": config.agentToken,
|
|
1068
|
+
};
|
|
1069
|
+
if (options.intent) headers["x-fibscope-report-intent"] = String(options.intent);
|
|
1070
|
+
const response = await fetch(config.reportUrl, {
|
|
1071
|
+
method: "POST",
|
|
1072
|
+
headers,
|
|
1073
|
+
body: stringify(report),
|
|
1074
|
+
});
|
|
1075
|
+
const text = await response.text();
|
|
1076
|
+
const payload = parsePayload(text);
|
|
1077
|
+
const code = payloadCode(payload);
|
|
1078
|
+
const retryAfterMs = retryAfterToMs(response.headers.get("retry-after"));
|
|
1079
|
+
if (response.status === 409) {
|
|
1080
|
+
return { ok: false, status: 409, code, error: describeAgentHttpError(payload, "STALE_REPORT"), payload };
|
|
1081
|
+
}
|
|
1082
|
+
if (!response.ok) {
|
|
1083
|
+
return { ok: false, status: response.status, code, error: describeAgentHttpError(payload, `HTTP_${response.status}`), payload, retryAfterMs };
|
|
1084
|
+
}
|
|
1085
|
+
return { ok: true, status: response.status, payload };
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
return { ok: false, status: 0, error: message(error) };
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function fetchAgentCommands(config) {
|
|
1092
|
+
const commandsUrl = config.commandsUrl ?? config.reportUrl.replace(/\/report\/?$/, "/commands");
|
|
1093
|
+
let response;
|
|
1094
|
+
try {
|
|
1095
|
+
response = await fetch(`${commandsUrl}${commandsUrl.includes("?") ? "&" : "?"}limit=10`, {
|
|
1096
|
+
headers: {
|
|
1097
|
+
"x-agent-token": config.agentToken,
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
throw new Error(`Command poll failed at ${commandsUrl}: ${message(error)}`);
|
|
1102
|
+
}
|
|
1103
|
+
const text = await response.text();
|
|
1104
|
+
const payload = parsePayload(text);
|
|
1105
|
+
if (!response.ok) {
|
|
1106
|
+
throw new Error(`Command poll failed: ${describeAgentHttpError(payload, `HTTP_${response.status}`)}`);
|
|
1107
|
+
}
|
|
1108
|
+
return payload && typeof payload === "object" ? payload : { commands: [] };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function executeAndReportCommand(runtime, command) {
|
|
1112
|
+
const config = runtime.config;
|
|
1113
|
+
const id = readCommandId(command);
|
|
1114
|
+
console.log(formatCommandEvent("received", command, config, "info"));
|
|
1115
|
+
try {
|
|
1116
|
+
const result = await executeAgentCommand(runtime, command);
|
|
1117
|
+
const body = {
|
|
1118
|
+
commandId: id,
|
|
1119
|
+
command: command.command,
|
|
1120
|
+
status: "completed",
|
|
1121
|
+
result,
|
|
1122
|
+
};
|
|
1123
|
+
await postCommandResult(config, body);
|
|
1124
|
+
console.log(formatCommandEvent("completed", command, config, "success"));
|
|
1125
|
+
return body;
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
const body = {
|
|
1128
|
+
commandId: id,
|
|
1129
|
+
command: command.command,
|
|
1130
|
+
status: "failed",
|
|
1131
|
+
error: message(error),
|
|
1132
|
+
};
|
|
1133
|
+
await postCommandResult(config, body);
|
|
1134
|
+
console.warn(formatCommandEvent("failed", command, config, "error", body.error));
|
|
1135
|
+
return body;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function executeAgentCommand(runtime, command) {
|
|
1140
|
+
const config = runtime.config ?? runtime;
|
|
1141
|
+
const name = normalizeCommandName(command?.command);
|
|
1142
|
+
const params = command?.params && typeof command.params === "object" ? command.params : {};
|
|
1143
|
+
if (name === "OPEN_CHANNEL" || name === "CREATE_CHANNEL") {
|
|
1144
|
+
return openFiberChannel(config, params);
|
|
1145
|
+
}
|
|
1146
|
+
if (name === "SETTLE_CHANNEL" || name === "SHUTDOWN_CHANNEL" || name === "CLOSE_CHANNEL") {
|
|
1147
|
+
return settleFiberChannel(runtime, params);
|
|
1148
|
+
}
|
|
1149
|
+
if (name === "FUNDING_WALLET_STATUS" || name === "REFRESH_FUNDING_BALANCE" || name === "UPDATE_FUNDING_BALANCE") {
|
|
1150
|
+
return refreshFundingBalance(runtime);
|
|
1151
|
+
}
|
|
1152
|
+
if (name === "CREATE_FUNDING_WALLET") {
|
|
1153
|
+
return createFundingWallet(config, params);
|
|
1154
|
+
}
|
|
1155
|
+
if (name === "LINK_FUNDING_WALLET") {
|
|
1156
|
+
return linkFundingWallet(config, params);
|
|
1157
|
+
}
|
|
1158
|
+
if (name === "SIMULATE_ROUTE") {
|
|
1159
|
+
return simulateFiberRoute(config, params);
|
|
1160
|
+
}
|
|
1161
|
+
if (name === "SEND_PAYMENT" || name === "PAY_INVOICE" || name === "FIBER_SEND_PAYMENT") {
|
|
1162
|
+
return sendFiberPayment(config, params);
|
|
1163
|
+
}
|
|
1164
|
+
if (name === "API_NODE_PAYMENT" || name === "NODE_PAYMENT") {
|
|
1165
|
+
return acceptApiNodePayment(runtime, params);
|
|
1166
|
+
}
|
|
1167
|
+
if (name === "API_WALLET_PAYMENT" || name === "WALLET_PAYMENT") {
|
|
1168
|
+
return acceptApiWalletPayment(runtime, params);
|
|
1169
|
+
}
|
|
1170
|
+
if (name === "PAYMENT_INTENT" || name === "ROUTE_INTENT" || name === "INTERNAL_ROUTE_INTENT") {
|
|
1171
|
+
return acknowledgePaymentIntent(config, params, { action: "ROUTE_INTENT" });
|
|
1172
|
+
}
|
|
1173
|
+
throw new Error(`Unsupported agent command: ${name || "unknown"}`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function normalizeCommandName(value) {
|
|
1177
|
+
return String(value ?? "")
|
|
1178
|
+
.trim()
|
|
1179
|
+
.toUpperCase()
|
|
1180
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
1181
|
+
.replace(/^_+|_+$/g, "");
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function simulateFiberRoute(config, params) {
|
|
1185
|
+
const localInfo = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs);
|
|
1186
|
+
const sourceNode = normalizePeerPubkey(textValue(params.sourceNode) || readNodeId(localInfo));
|
|
1187
|
+
const peer = resolveSimulationPeer(params);
|
|
1188
|
+
const destinationNode = normalizePeerPubkey(peer.pubkey);
|
|
1189
|
+
if (peerKey(sourceNode) === peerKey(destinationNode)) {
|
|
1190
|
+
throw new Error("Route simulation needs a destination peer that is different from the selected source node.");
|
|
1191
|
+
}
|
|
1192
|
+
const amount = simulationAmount(params);
|
|
1193
|
+
const assetType = textValue(params.asset ?? params.assetType ?? params.asset_type) || "CKB";
|
|
1194
|
+
const componentState = await collectFiberStateReport({
|
|
1195
|
+
rpcUrl: config.fiberRpcUrl,
|
|
1196
|
+
timeoutMs: config.timeoutMs ?? 8_000,
|
|
1197
|
+
});
|
|
1198
|
+
const result = await virtualizePayment(
|
|
1199
|
+
{
|
|
1200
|
+
sourceNode,
|
|
1201
|
+
destinationNode,
|
|
1202
|
+
amount,
|
|
1203
|
+
assetType,
|
|
1204
|
+
},
|
|
1205
|
+
{
|
|
1206
|
+
componentState,
|
|
1207
|
+
rpcUrl: config.fiberRpcUrl,
|
|
1208
|
+
timeoutMs: config.timeoutMs ?? 8_000,
|
|
1209
|
+
maxHops: positiveNumber(params.maxHops, 6),
|
|
1210
|
+
maxRoutes: positiveNumber(params.maxRoutes, 10),
|
|
1211
|
+
},
|
|
1212
|
+
);
|
|
1213
|
+
return normalizeRouteSimulationResult(result, {
|
|
1214
|
+
sourceNode,
|
|
1215
|
+
destinationNode,
|
|
1216
|
+
amount,
|
|
1217
|
+
assetType,
|
|
1218
|
+
peer,
|
|
1219
|
+
publicFee: normalizeSimulationPublicFee(params.publicFee ?? params.nodeFee ?? params.fee),
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function resolveSimulationPeer(params) {
|
|
1224
|
+
const hasPeerToken = firstTextValue(params.peerToken, params.fibscopePeerToken);
|
|
1225
|
+
if (hasPeerToken) return resolveCommandPeer(params);
|
|
1226
|
+
const pubkey = firstTextValue(params.destinationNode, params.peerPubkey, params.selectedPeerPubkey, params.pubkey, params.peer);
|
|
1227
|
+
return {
|
|
1228
|
+
pubkey,
|
|
1229
|
+
address: firstTextValue(params.peerAddress, params.address),
|
|
1230
|
+
meta: {
|
|
1231
|
+
source: params.mode === "online" ? "online-peer" : params.mode === "token" ? "fibscope-token" : "advanced-pubkey",
|
|
1232
|
+
},
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function simulationAmount(params) {
|
|
1237
|
+
if (params.ckb !== undefined && params.ckb !== "") return ckbToShannons(params.ckb).toString();
|
|
1238
|
+
if (params.amountCkb !== undefined && params.amountCkb !== "") return ckbToShannons(params.amountCkb).toString();
|
|
1239
|
+
const value = params.amount ?? params.amountShannons ?? params.value;
|
|
1240
|
+
if (value !== undefined && value !== "") return quantity(value).startsWith("0x") ? BigInt(quantity(value)).toString() : String(value);
|
|
1241
|
+
return ckbToShannons("1").toString();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function normalizeRouteSimulationResult(result, context) {
|
|
1245
|
+
const simulation = result.simulation ?? {};
|
|
1246
|
+
const steps = Array.isArray(simulation.steps) ? simulation.steps : [];
|
|
1247
|
+
const predictions = Array.isArray(result.predictions) ? result.predictions : [];
|
|
1248
|
+
const baseFeeBreakdown = simulation.feeBreakdown ?? {};
|
|
1249
|
+
const baseTotalFee = bigintValue(baseFeeBreakdown.totalFee ?? simulation.totalFee, 0n);
|
|
1250
|
+
const publicFeeAmount = simulation.result === "success"
|
|
1251
|
+
? estimateSimulationPublicFee(context.publicFee, context.amount)
|
|
1252
|
+
: 0n;
|
|
1253
|
+
const totalFee = baseTotalFee + publicFeeAmount;
|
|
1254
|
+
const totalDebit = bigintValue(simulation.totalDebit, bigintValue(context.amount, 0n) + baseTotalFee) + publicFeeAmount;
|
|
1255
|
+
const feeBreakdown = withSimulationPublicFeeBreakdown(baseFeeBreakdown, context.publicFee, publicFeeAmount);
|
|
1256
|
+
const augmentedSimulation = {
|
|
1257
|
+
...simulation,
|
|
1258
|
+
totalFee,
|
|
1259
|
+
totalDebit,
|
|
1260
|
+
feeBreakdown,
|
|
1261
|
+
};
|
|
1262
|
+
return {
|
|
1263
|
+
ok: true,
|
|
1264
|
+
action: "SIMULATE_ROUTE",
|
|
1265
|
+
source: "agent-route-virtualizer",
|
|
1266
|
+
mode: "Read-only route simulation",
|
|
1267
|
+
status: simulation.result === "success" ? "success" : "failed",
|
|
1268
|
+
request: {
|
|
1269
|
+
sourceNode: context.sourceNode,
|
|
1270
|
+
destinationNode: context.destinationNode,
|
|
1271
|
+
amount: context.amount,
|
|
1272
|
+
assetType: context.assetType,
|
|
1273
|
+
peer: context.peer.meta,
|
|
1274
|
+
},
|
|
1275
|
+
totalFee,
|
|
1276
|
+
route: steps.map((step) => ({
|
|
1277
|
+
nodeId: step.toNode,
|
|
1278
|
+
peerId: step.toNode,
|
|
1279
|
+
channelId: step.channelId,
|
|
1280
|
+
fee: step.hopFee,
|
|
1281
|
+
status: step.status,
|
|
1282
|
+
failed: step.status === "blocked",
|
|
1283
|
+
reason: step.reason,
|
|
1284
|
+
})),
|
|
1285
|
+
warnings: predictions.map((prediction) => ({
|
|
1286
|
+
code: prediction.code,
|
|
1287
|
+
title: prediction.code,
|
|
1288
|
+
message: prediction.explanation ?? prediction.recommendation,
|
|
1289
|
+
recommendation: prediction.recommendation,
|
|
1290
|
+
confidence: prediction.confidence,
|
|
1291
|
+
})),
|
|
1292
|
+
liquidityChanges: Array.isArray(simulation.liquidityChanges) ? simulation.liquidityChanges : [],
|
|
1293
|
+
feeBreakdown,
|
|
1294
|
+
diagnostics: result.diagnostics ?? [],
|
|
1295
|
+
selectedRoute: result.selectedRoute ?? null,
|
|
1296
|
+
routes: result.routes ?? [],
|
|
1297
|
+
simulation: augmentedSimulation,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function normalizeSimulationPublicFee(value) {
|
|
1302
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1303
|
+
const enabled = value.enabled === true || String(value.enabled ?? "").toLowerCase() === "true";
|
|
1304
|
+
const mode = textValue(value.mode).toLowerCase() === "fixed" ? "fixed" : "percent";
|
|
1305
|
+
const feeValue = textField(value.value ?? value.feeValue ?? value.amount ?? value.rate);
|
|
1306
|
+
if (!enabled || !feeValue) return undefined;
|
|
1307
|
+
return { enabled: true, mode, value: feeValue };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function estimateSimulationPublicFee(publicFee, amount) {
|
|
1311
|
+
if (!publicFee?.enabled) return 0n;
|
|
1312
|
+
try {
|
|
1313
|
+
if (publicFee.mode === "fixed") return ckbToShannons(publicFee.value);
|
|
1314
|
+
return percentFee(bigintValue(amount, 0n), publicFee.value);
|
|
1315
|
+
} catch {
|
|
1316
|
+
return 0n;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function withSimulationPublicFeeBreakdown(feeBreakdown, publicFee, publicFeeAmount) {
|
|
1321
|
+
const base = feeBreakdown && typeof feeBreakdown === "object" && !Array.isArray(feeBreakdown) ? feeBreakdown : {};
|
|
1322
|
+
const hopFees = Array.isArray(base.hopFees) ? base.hopFees : [];
|
|
1323
|
+
const hopTotal = hopFees.reduce((sum, hop) => sum + bigintValue(hop?.fee ?? hop?.hopFee, 0n), 0n);
|
|
1324
|
+
const baseTotal = bigintValue(base.totalFee, hopTotal);
|
|
1325
|
+
const feeRows = hopFees.map((hop, index) => ({
|
|
1326
|
+
label: `Hop ${index + 1}`,
|
|
1327
|
+
nodeId: hop?.nodeId,
|
|
1328
|
+
channelId: hop?.channelId,
|
|
1329
|
+
fee: hop?.fee ?? hop?.hopFee ?? 0n,
|
|
1330
|
+
}));
|
|
1331
|
+
if (publicFee?.enabled && publicFeeAmount > 0n) {
|
|
1332
|
+
feeRows.push({
|
|
1333
|
+
label: "Node fee",
|
|
1334
|
+
fee: publicFeeAmount,
|
|
1335
|
+
mode: publicFee.mode,
|
|
1336
|
+
value: publicFee.value,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
...base,
|
|
1341
|
+
hopFees,
|
|
1342
|
+
feeRows,
|
|
1343
|
+
publicFee: publicFee?.enabled ? {
|
|
1344
|
+
mode: publicFee.mode,
|
|
1345
|
+
value: publicFee.value,
|
|
1346
|
+
fee: publicFeeAmount,
|
|
1347
|
+
} : undefined,
|
|
1348
|
+
totalFee: baseTotal + publicFeeAmount,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function percentFee(amount, rateValue) {
|
|
1353
|
+
const text = String(rateValue ?? "").trim();
|
|
1354
|
+
const match = /^([0-9]+)(?:\.([0-9]{1,8}))?$/.exec(text);
|
|
1355
|
+
if (!match) return 0n;
|
|
1356
|
+
const fraction = match[2] ?? "";
|
|
1357
|
+
const numerator = BigInt(`${match[1]}${fraction}`);
|
|
1358
|
+
const denominator = 100n * (10n ** BigInt(fraction.length));
|
|
1359
|
+
if (numerator <= 0n || denominator <= 0n) return 0n;
|
|
1360
|
+
return ceilDiv(amount * numerator, denominator);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function ceilDiv(value, divisor) {
|
|
1364
|
+
return value <= 0n ? 0n : (value + divisor - 1n) / divisor;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function bigintValue(value, fallback = 0n) {
|
|
1368
|
+
if (typeof value === "bigint") return value;
|
|
1369
|
+
if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value));
|
|
1370
|
+
if (typeof value === "string" && /^[0-9]+$/.test(value.trim())) return BigInt(value.trim());
|
|
1371
|
+
return fallback;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function textField(value) {
|
|
1375
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
1376
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1377
|
+
return "";
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
async function refreshFundingBalance(runtime) {
|
|
1381
|
+
const config = runtime.config ?? runtime;
|
|
1382
|
+
const report = typeof runtime.collect === "function"
|
|
1383
|
+
? await runtime.collect()
|
|
1384
|
+
: await collectReport(config);
|
|
1385
|
+
let pushResult = { ok: false, skipped: true };
|
|
1386
|
+
if (typeof runtime.push === "function") {
|
|
1387
|
+
pushResult = await pushWithRegistrationRetry(runtime, report, {
|
|
1388
|
+
intent: "funding-refresh",
|
|
1389
|
+
retryOnRateLimit: true,
|
|
1390
|
+
maxRateLimitRetries: 2,
|
|
1391
|
+
});
|
|
1392
|
+
if (!pushResult.ok) {
|
|
1393
|
+
throw new Error(`Funding balance refreshed locally, but the dashboard update failed: ${pushResult.error ?? "Fibscope rejected the report"}`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
const funding = report.funding ?? {};
|
|
1397
|
+
const wallet = report.fundingWallet ?? {};
|
|
1398
|
+
return {
|
|
1399
|
+
ok: true,
|
|
1400
|
+
action: "REFRESH_FUNDING_BALANCE",
|
|
1401
|
+
message: "Funding balance updated from the agent.",
|
|
1402
|
+
checkedAt: report.collectedAt ?? Date.now(),
|
|
1403
|
+
funding,
|
|
1404
|
+
fundingWallet: wallet,
|
|
1405
|
+
push: {
|
|
1406
|
+
ok: Boolean(pushResult.ok),
|
|
1407
|
+
status: pushResult.status,
|
|
1408
|
+
nodeId: pushResult.payload?.nodeId,
|
|
1409
|
+
},
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
async function acknowledgePaymentIntent(config, params, options = {}) {
|
|
1414
|
+
const info = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs);
|
|
1415
|
+
const nodePubkey = normalizePeerPubkey(readNodeId(info));
|
|
1416
|
+
const assetType = textValue(params.asset ?? params.assetType) || "CKB";
|
|
1417
|
+
const requesterAddress = textValue(params.requesterAddress);
|
|
1418
|
+
const recipients = paymentIntentRecipients(params);
|
|
1419
|
+
const amount = textValue(params.amount) || totalRecipientAmount(recipients) || "0";
|
|
1420
|
+
const targetType = recipients[0]?.targetType ?? (textValue(params.targetType).toLowerCase() === "wallet" ? "wallet" : "node");
|
|
1421
|
+
const recipientAddress = (recipients[0]?.recipientAddress ?? textValue(params.recipientAddress)) || undefined;
|
|
1422
|
+
const fee = params.fee && typeof params.fee === "object" ? params.fee : undefined;
|
|
1423
|
+
if (recipients.some((recipient) => recipient.targetType === "wallet" && !recipient.recipientAddress)) throw new Error("Payment intent is missing a recipient wallet address.");
|
|
1424
|
+
return {
|
|
1425
|
+
ok: true,
|
|
1426
|
+
action: options.action ?? "PAYMENT_INTENT",
|
|
1427
|
+
mode: options.mode ?? "Internal route intent",
|
|
1428
|
+
message: options.message ?? "Internal payment route intent acknowledged by the node agent.",
|
|
1429
|
+
intentId: textValue(params.intentId) || undefined,
|
|
1430
|
+
requesterAddress: requesterAddress || undefined,
|
|
1431
|
+
amount,
|
|
1432
|
+
assetType,
|
|
1433
|
+
targetType,
|
|
1434
|
+
recipientAddress,
|
|
1435
|
+
recipients,
|
|
1436
|
+
fee,
|
|
1437
|
+
note: textValue(params.note) || undefined,
|
|
1438
|
+
node: {
|
|
1439
|
+
pubkey: nodePubkey,
|
|
1440
|
+
version: textValue(info?.version) || undefined,
|
|
1441
|
+
},
|
|
1442
|
+
acknowledgedAt: Date.now(),
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async function sendFiberPayment(config, params) {
|
|
1447
|
+
const invoice = firstTextValue(params.invoice, params.fiberInvoice, params.paymentRequest, params.payment_request);
|
|
1448
|
+
const targetPubkey = firstTextValue(params.targetPubkey, params.target_pubkey, params.destinationNode, params.destination_node, params.pubkey);
|
|
1449
|
+
const amount = firstTextValue(params.amountShannons, params.amount, params.value);
|
|
1450
|
+
const maxFeeAmount = firstTextValue(params.maxFeeAmount, params.max_fee_amount);
|
|
1451
|
+
const request = {};
|
|
1452
|
+
if (invoice) request.invoice = invoice;
|
|
1453
|
+
else {
|
|
1454
|
+
if (!targetPubkey) throw new Error("Fiber send_payment requires either an invoice/payment request or a target pubkey.");
|
|
1455
|
+
if (!amount) throw new Error("Fiber keysend payment requires an amount.");
|
|
1456
|
+
request.target_pubkey = normalizePeerPubkey(targetPubkey);
|
|
1457
|
+
request.keysend = boolValue(params.keysend, true);
|
|
1458
|
+
}
|
|
1459
|
+
if (amount) request.amount = quantity(amount);
|
|
1460
|
+
if (maxFeeAmount) request.max_fee_amount = quantity(maxFeeAmount);
|
|
1461
|
+
const result = await rpcCall(config.fiberRpcUrl, "send_payment", [request], config.timeoutMs);
|
|
1462
|
+
return {
|
|
1463
|
+
ok: true,
|
|
1464
|
+
action: "SEND_PAYMENT",
|
|
1465
|
+
mode: "Real Fiber invoice payment",
|
|
1466
|
+
paymentId: textValue(params.paymentId) || undefined,
|
|
1467
|
+
quoteId: textValue(params.quoteId) || undefined,
|
|
1468
|
+
candidateId: textValue(params.candidateId) || undefined,
|
|
1469
|
+
invoice: invoice || undefined,
|
|
1470
|
+
targetPubkey: targetPubkey || undefined,
|
|
1471
|
+
amount: amount || undefined,
|
|
1472
|
+
assetType: textValue(params.assetType ?? params.asset) || "CKB",
|
|
1473
|
+
result,
|
|
1474
|
+
sentAt: Date.now(),
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async function acceptApiWalletPayment(runtime, params) {
|
|
1479
|
+
const config = runtime.config ?? runtime;
|
|
1480
|
+
const info = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs);
|
|
1481
|
+
const depositTxHash = textValue(params.depositTxHash);
|
|
1482
|
+
const deposit = depositTxHash ? await waitForCkbTransaction(config, depositTxHash, params) : undefined;
|
|
1483
|
+
const payout = await executeProviderWalletPayout(config, {
|
|
1484
|
+
receiverWallet: textValue(params.receiverWallet),
|
|
1485
|
+
amount: textValue(params.amountShannons ?? params.amount),
|
|
1486
|
+
paymentId: textValue(params.paymentId),
|
|
1487
|
+
depositTxHash,
|
|
1488
|
+
});
|
|
1489
|
+
let refreshed;
|
|
1490
|
+
if (typeof runtime.collect === "function" && typeof runtime.push === "function") {
|
|
1491
|
+
refreshed = await pushWithRegistrationRetry(runtime, await runtime.collect(), {
|
|
1492
|
+
intent: "api-wallet-payment-completed",
|
|
1493
|
+
retryOnRateLimit: true,
|
|
1494
|
+
maxRateLimitRetries: 1,
|
|
1495
|
+
}).catch((error) => ({ ok: false, error: message(error) }));
|
|
1496
|
+
}
|
|
1497
|
+
return {
|
|
1498
|
+
ok: true,
|
|
1499
|
+
action: "API_WALLET_PAYMENT",
|
|
1500
|
+
mode: "API-managed wallet payment direct payout",
|
|
1501
|
+
paymentStatus: "completed",
|
|
1502
|
+
message: "Sender deposit is confirmed and the provider funding wallet payout to the recipient wallet was broadcast.",
|
|
1503
|
+
paymentId: textValue(params.paymentId) || undefined,
|
|
1504
|
+
quoteId: textValue(params.quoteId) || undefined,
|
|
1505
|
+
candidateId: textValue(params.candidateId) || undefined,
|
|
1506
|
+
senderWallet: textValue(params.senderWallet) || undefined,
|
|
1507
|
+
receiverWallet: textValue(params.receiverWallet) || undefined,
|
|
1508
|
+
amount: textValue(params.amountShannons ?? params.amount) || undefined,
|
|
1509
|
+
totalDebit: textValue(params.totalDebit) || undefined,
|
|
1510
|
+
feeAmount: textValue(params.feeAmount) || undefined,
|
|
1511
|
+
assetType: textValue(params.assetType ?? params.asset) || "CKB",
|
|
1512
|
+
providerId: textValue(params.providerId) || undefined,
|
|
1513
|
+
providerName: textValue(params.providerName) || undefined,
|
|
1514
|
+
serviceDepositAddress: textValue(params.serviceDepositAddress) || undefined,
|
|
1515
|
+
depositTxHash: depositTxHash || undefined,
|
|
1516
|
+
depositAmount: textValue(params.depositAmount) || undefined,
|
|
1517
|
+
deposit,
|
|
1518
|
+
payout,
|
|
1519
|
+
fundingRefresh: refreshed,
|
|
1520
|
+
routeExecution: {
|
|
1521
|
+
status: "direct_wallet_payout",
|
|
1522
|
+
reason: "Plain CKB wallet recipients are not Fiber channel endpoints, so this executor pays the recipient wallet on-chain from the provider funding wallet after confirming the payer deposit.",
|
|
1523
|
+
},
|
|
1524
|
+
node: {
|
|
1525
|
+
pubkey: normalizePeerPubkey(readNodeId(info)),
|
|
1526
|
+
version: textValue(info?.version) || undefined,
|
|
1527
|
+
},
|
|
1528
|
+
completedAt: Date.now(),
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async function acceptApiNodePayment(runtime, params) {
|
|
1533
|
+
const config = runtime.config ?? runtime;
|
|
1534
|
+
const info = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs);
|
|
1535
|
+
const depositTxHash = textValue(params.depositTxHash);
|
|
1536
|
+
const deposit = depositTxHash ? await waitForCkbTransaction(config, depositTxHash, params) : undefined;
|
|
1537
|
+
const targetPubkey = normalizePeerPubkey(firstTextValue(params.receiverNode, params.targetPubkey, params.destinationNode, params.pubkey));
|
|
1538
|
+
const fiberPayment = await sendFiberPayment(config, {
|
|
1539
|
+
...params,
|
|
1540
|
+
targetPubkey,
|
|
1541
|
+
amountShannons: textValue(params.amountShannons ?? params.amount),
|
|
1542
|
+
keysend: true,
|
|
1543
|
+
});
|
|
1544
|
+
let refreshed;
|
|
1545
|
+
if (typeof runtime.collect === "function" && typeof runtime.push === "function") {
|
|
1546
|
+
refreshed = await pushWithRegistrationRetry(runtime, await runtime.collect(), {
|
|
1547
|
+
intent: "api-node-payment-completed",
|
|
1548
|
+
retryOnRateLimit: true,
|
|
1549
|
+
maxRateLimitRetries: 1,
|
|
1550
|
+
}).catch((error) => ({ ok: false, error: message(error) }));
|
|
1551
|
+
}
|
|
1552
|
+
return {
|
|
1553
|
+
ok: true,
|
|
1554
|
+
action: "API_NODE_PAYMENT",
|
|
1555
|
+
mode: "API-managed Fiber node payment",
|
|
1556
|
+
paymentStatus: "completed",
|
|
1557
|
+
message: "Sender deposit is confirmed and the provider agent submitted the Fiber payment to the recipient node.",
|
|
1558
|
+
paymentId: textValue(params.paymentId) || undefined,
|
|
1559
|
+
quoteId: textValue(params.quoteId) || undefined,
|
|
1560
|
+
candidateId: textValue(params.candidateId) || undefined,
|
|
1561
|
+
senderWallet: textValue(params.senderWallet) || undefined,
|
|
1562
|
+
receiverNode: targetPubkey,
|
|
1563
|
+
targetPubkey,
|
|
1564
|
+
amount: textValue(params.amountShannons ?? params.amount) || undefined,
|
|
1565
|
+
totalDebit: textValue(params.totalDebit) || undefined,
|
|
1566
|
+
feeAmount: textValue(params.feeAmount) || undefined,
|
|
1567
|
+
assetType: textValue(params.assetType ?? params.asset) || "CKB",
|
|
1568
|
+
providerId: textValue(params.providerId) || undefined,
|
|
1569
|
+
providerName: textValue(params.providerName) || undefined,
|
|
1570
|
+
serviceDepositAddress: textValue(params.serviceDepositAddress) || undefined,
|
|
1571
|
+
depositTxHash: depositTxHash || undefined,
|
|
1572
|
+
depositAmount: textValue(params.depositAmount) || undefined,
|
|
1573
|
+
deposit,
|
|
1574
|
+
fiberPayment,
|
|
1575
|
+
fundingRefresh: refreshed,
|
|
1576
|
+
routeExecution: {
|
|
1577
|
+
status: "fiber_send_payment",
|
|
1578
|
+
reason: "Fiber node recipients are channel endpoints, so the provider uses outbound Fiber liquidity after confirming the payer deposit.",
|
|
1579
|
+
},
|
|
1580
|
+
node: {
|
|
1581
|
+
pubkey: normalizePeerPubkey(readNodeId(info)),
|
|
1582
|
+
version: textValue(info?.version) || undefined,
|
|
1583
|
+
},
|
|
1584
|
+
completedAt: Date.now(),
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async function waitForCkbTransaction(config, txHash, params = {}) {
|
|
1589
|
+
const rpcUrl = resolveCkbIndexerUrl(config);
|
|
1590
|
+
if (!rpcUrl) throw new Error("No CKB RPC URL is configured for deposit confirmation.");
|
|
1591
|
+
const timeoutMs = positiveNumber(params.depositConfirmTimeoutMs ?? config.depositConfirmTimeoutMs, 2 * 60_000);
|
|
1592
|
+
const pollMs = positiveNumber(params.depositConfirmPollMs ?? config.depositConfirmPollMs, 5_000);
|
|
1593
|
+
const startedAt = Date.now();
|
|
1594
|
+
let lastStatus = "unknown";
|
|
1595
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
1596
|
+
const response = await rpcCall(rpcUrl, "get_transaction", [txHash], config.ckbRpcTimeoutMs ?? config.timeoutMs ?? 10_000);
|
|
1597
|
+
const status = textValue(response?.tx_status?.status ?? response?.txStatus?.status);
|
|
1598
|
+
if (status) lastStatus = status;
|
|
1599
|
+
if (status === "committed") {
|
|
1600
|
+
return {
|
|
1601
|
+
txHash,
|
|
1602
|
+
status,
|
|
1603
|
+
blockHash: textValue(response?.tx_status?.block_hash ?? response?.txStatus?.blockHash) || undefined,
|
|
1604
|
+
blockNumber: textValue(response?.tx_status?.block_number ?? response?.txStatus?.blockNumber) || undefined,
|
|
1605
|
+
confirmedAt: Date.now(),
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
const reason = textValue(response?.tx_status?.reason ?? response?.txStatus?.reason);
|
|
1609
|
+
if (status === "rejected") throw new Error(`Deposit transaction was rejected${reason ? `: ${reason}` : "."}`);
|
|
1610
|
+
await sleep(pollMs);
|
|
1611
|
+
}
|
|
1612
|
+
throw new Error(`Deposit transaction ${shortText(txHash, 10, 8)} was not committed within ${timeoutMs}ms. Last status: ${lastStatus}.`);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
async function executeProviderWalletPayout(config, input) {
|
|
1616
|
+
const receiverWallet = textValue(input.receiverWallet);
|
|
1617
|
+
if (!receiverWallet) throw new Error("API wallet payment is missing a recipient wallet address.");
|
|
1618
|
+
const amountShannons = amountBigInt(input.amount);
|
|
1619
|
+
if (amountShannons <= 0n) throw new Error("API wallet payment payout amount must be positive.");
|
|
1620
|
+
const walletPath = resolveFundingWalletPath(config);
|
|
1621
|
+
if (!walletPath || !existsSync(walletPath)) throw new Error("Provider funding wallet is not linked, so recipient payout cannot be signed.");
|
|
1622
|
+
const providerWallet = await readFundingWallet(walletPath, config.chain ?? "testnet");
|
|
1623
|
+
const rpcUrl = resolveCkbIndexerUrl(config);
|
|
1624
|
+
if (!rpcUrl) throw new Error("No CKB RPC URL is configured for recipient payout.");
|
|
1625
|
+
const cells = await collectProviderSpendableCells({
|
|
1626
|
+
rpcUrl,
|
|
1627
|
+
lockScript: providerWallet.lockScript,
|
|
1628
|
+
targetCapacity: amountShannons + MIN_SECP_CELL_CAPACITY + 1_000_000n,
|
|
1629
|
+
preferredTxHash: textValue(input.depositTxHash),
|
|
1630
|
+
config,
|
|
1631
|
+
});
|
|
1632
|
+
const built = buildSignedCkbPayoutTransaction({
|
|
1633
|
+
cells,
|
|
1634
|
+
recipientLock: addressToScript(receiverWallet),
|
|
1635
|
+
changeLock: providerWallet.sdkLockScript,
|
|
1636
|
+
privateKey: providerWallet.privateKeyHex,
|
|
1637
|
+
amountShannons,
|
|
1638
|
+
chain: config.chain ?? "testnet",
|
|
1639
|
+
});
|
|
1640
|
+
const txHash = await rpcCall(rpcUrl, "send_transaction", [toRpcTransaction(built.transaction)], config.ckbRpcTimeoutMs ?? config.timeoutMs ?? 10_000);
|
|
1641
|
+
return {
|
|
1642
|
+
status: "broadcast",
|
|
1643
|
+
txHash,
|
|
1644
|
+
paymentId: textValue(input.paymentId) || undefined,
|
|
1645
|
+
receiverWallet,
|
|
1646
|
+
amount: amountShannons.toString(),
|
|
1647
|
+
amountLabel: ckbText(amountShannons),
|
|
1648
|
+
feeShannons: built.fee.toString(),
|
|
1649
|
+
feeLabel: ckbText(built.fee),
|
|
1650
|
+
inputCapacity: built.inputCapacity.toString(),
|
|
1651
|
+
changeCapacity: built.changeCapacity.toString(),
|
|
1652
|
+
inputCount: cells.length,
|
|
1653
|
+
rpcUrl,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
async function collectProviderSpendableCells({ rpcUrl, lockScript, targetCapacity, preferredTxHash, config }) {
|
|
1658
|
+
const searchKey = {
|
|
1659
|
+
script: lockScript,
|
|
1660
|
+
script_type: "lock",
|
|
1661
|
+
};
|
|
1662
|
+
const cells = [];
|
|
1663
|
+
let cursor;
|
|
1664
|
+
const limit = hexQuantity(positiveNumber(config.ckbCellPageSize, 100));
|
|
1665
|
+
const maxPages = Math.max(1, Math.min(50, Math.trunc(positiveNumber(config.ckbCellMaxPages, 20))));
|
|
1666
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
1667
|
+
const params = [searchKey, "asc", limit];
|
|
1668
|
+
if (cursor) params.push(cursor);
|
|
1669
|
+
const response = await rpcCall(rpcUrl, "get_cells", params, config.ckbRpcTimeoutMs ?? config.timeoutMs ?? 10_000);
|
|
1670
|
+
const objects = Array.isArray(response?.objects) ? response.objects : [];
|
|
1671
|
+
for (const cell of objects) {
|
|
1672
|
+
const output = cell?.output ?? cell?.cell_output ?? {};
|
|
1673
|
+
const data = textValue(cell?.output_data ?? cell?.outputData) || "0x";
|
|
1674
|
+
if (output.type || data !== "0x") continue;
|
|
1675
|
+
const outPoint = normalizeOutPoint(cell?.out_point ?? cell?.outPoint);
|
|
1676
|
+
const capacity = amountBigInt(output.capacity);
|
|
1677
|
+
if (!outPoint || capacity <= 0n) continue;
|
|
1678
|
+
cells.push({ outPoint, capacity });
|
|
1679
|
+
}
|
|
1680
|
+
const nextCursor = typeof response?.last_cursor === "string" && response.last_cursor !== cursor ? response.last_cursor : undefined;
|
|
1681
|
+
if (!objects.length || !nextCursor || nextCursor === "0x") break;
|
|
1682
|
+
cursor = nextCursor;
|
|
1683
|
+
}
|
|
1684
|
+
const preferred = textValue(preferredTxHash).toLowerCase();
|
|
1685
|
+
cells.sort((left, right) => {
|
|
1686
|
+
const leftPreferred = preferred && left.outPoint.txHash.toLowerCase() === preferred ? 0 : 1;
|
|
1687
|
+
const rightPreferred = preferred && right.outPoint.txHash.toLowerCase() === preferred ? 0 : 1;
|
|
1688
|
+
return leftPreferred - rightPreferred;
|
|
1689
|
+
});
|
|
1690
|
+
const selected = [];
|
|
1691
|
+
let total = 0n;
|
|
1692
|
+
for (const cell of cells) {
|
|
1693
|
+
selected.push(cell);
|
|
1694
|
+
total += cell.capacity;
|
|
1695
|
+
if (total >= targetCapacity) break;
|
|
1696
|
+
}
|
|
1697
|
+
if (total < targetCapacity) {
|
|
1698
|
+
throw new Error(`Provider funding wallet has insufficient spendable CKB for recipient payout. Need ${ckbText(targetCapacity)}, found ${ckbText(total)}.`);
|
|
1699
|
+
}
|
|
1700
|
+
return selected;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function buildSignedCkbPayoutTransaction(input) {
|
|
1704
|
+
const inputCapacity = input.cells.reduce((total, cell) => total + cell.capacity, 0n);
|
|
1705
|
+
if (inputCapacity <= input.amountShannons) throw new Error("Provider funding wallet does not have enough CKB for payout and network fee.");
|
|
1706
|
+
let changeCapacity = inputCapacity - input.amountShannons;
|
|
1707
|
+
let fee = 0n;
|
|
1708
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1709
|
+
if (changeCapacity < MIN_SECP_CELL_CAPACITY) {
|
|
1710
|
+
throw new Error(`Provider funding wallet cannot create a valid CKB change cell after payout. It needs at least ${ckbText(MIN_SECP_CELL_CAPACITY - changeCapacity)} more change capacity.`);
|
|
1711
|
+
}
|
|
1712
|
+
const unsigned = ckbPayoutTransaction(input.cells, input.recipientLock, input.changeLock, input.amountShannons, changeCapacity, input.chain);
|
|
1713
|
+
fee = ckbTransactionFee(unsigned, DEFAULT_PAYOUT_FEE_RATE);
|
|
1714
|
+
const nextChange = inputCapacity - input.amountShannons - fee;
|
|
1715
|
+
if (nextChange < 0n) throw new Error("Provider funding wallet does not have enough CKB for payout and network fee.");
|
|
1716
|
+
if (nextChange === changeCapacity) {
|
|
1717
|
+
return {
|
|
1718
|
+
transaction: signCkbTransaction(unsigned, input.privateKey),
|
|
1719
|
+
fee,
|
|
1720
|
+
inputCapacity,
|
|
1721
|
+
changeCapacity,
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
changeCapacity = nextChange;
|
|
1725
|
+
}
|
|
1726
|
+
const unsigned = ckbPayoutTransaction(input.cells, input.recipientLock, input.changeLock, input.amountShannons, changeCapacity, input.chain);
|
|
1727
|
+
return {
|
|
1728
|
+
transaction: signCkbTransaction(unsigned, input.privateKey),
|
|
1729
|
+
fee,
|
|
1730
|
+
inputCapacity,
|
|
1731
|
+
changeCapacity,
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function ckbPayoutTransaction(cells, recipientLock, changeLock, amountShannons, changeCapacity, chain) {
|
|
1736
|
+
return {
|
|
1737
|
+
version: "0x0",
|
|
1738
|
+
cellDeps: [{
|
|
1739
|
+
outPoint: secpCellDepOutPoint(chain),
|
|
1740
|
+
depType: systemScripts.SECP256K1_BLAKE160.depType,
|
|
1741
|
+
}],
|
|
1742
|
+
headerDeps: [],
|
|
1743
|
+
inputs: cells.map((cell) => ({
|
|
1744
|
+
since: "0x0",
|
|
1745
|
+
previousOutput: cell.outPoint,
|
|
1746
|
+
})),
|
|
1747
|
+
outputs: [
|
|
1748
|
+
{
|
|
1749
|
+
capacity: quantity(amountShannons),
|
|
1750
|
+
lock: recipientLock,
|
|
1751
|
+
type: null,
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
capacity: quantity(changeCapacity),
|
|
1755
|
+
lock: changeLock,
|
|
1756
|
+
type: null,
|
|
1757
|
+
},
|
|
1758
|
+
],
|
|
1759
|
+
outputsData: ["0x", "0x"],
|
|
1760
|
+
witnesses: cells.map(() => "0x"),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function ckbTransactionFee(transaction, feeRate) {
|
|
1765
|
+
const placeholder = {
|
|
1766
|
+
...transaction,
|
|
1767
|
+
witnesses: signingPlaceholderWitnesses(transaction.inputs.length),
|
|
1768
|
+
};
|
|
1769
|
+
return BigInt(calculateTransactionFee(BigInt(getTransactionSize(placeholder)), feeRate));
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function signCkbTransaction(transaction, privateKey) {
|
|
1773
|
+
const witnessesForSigning = signingPlaceholderWitnesses(transaction.inputs.length);
|
|
1774
|
+
const message = ckbSighashAllMessage(transaction, witnessesForSigning);
|
|
1775
|
+
const signature = new ECPair(privateKey).signRecoverable(message);
|
|
1776
|
+
return {
|
|
1777
|
+
...transaction,
|
|
1778
|
+
witnesses: [
|
|
1779
|
+
serializeWitnessArgs({ lock: signature, inputType: "", outputType: "" }),
|
|
1780
|
+
...transaction.inputs.slice(1).map(() => "0x"),
|
|
1781
|
+
],
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function signingPlaceholderWitnesses(inputCount) {
|
|
1786
|
+
if (inputCount <= 0) return [];
|
|
1787
|
+
return [
|
|
1788
|
+
serializeWitnessArgs({ lock: EMPTY_SECP_SIG, inputType: "", outputType: "" }),
|
|
1789
|
+
...Array.from({ length: inputCount - 1 }, () => "0x"),
|
|
1790
|
+
];
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function ckbSighashAllMessage(transaction, witnesses) {
|
|
1794
|
+
const txHash = rawTransactionToHash({
|
|
1795
|
+
version: transaction.version,
|
|
1796
|
+
cellDeps: transaction.cellDeps,
|
|
1797
|
+
headerDeps: transaction.headerDeps,
|
|
1798
|
+
inputs: transaction.inputs,
|
|
1799
|
+
outputs: transaction.outputs,
|
|
1800
|
+
outputsData: transaction.outputsData,
|
|
1801
|
+
});
|
|
1802
|
+
const hasher = blake2b(32, null, null, PERSONAL);
|
|
1803
|
+
hasher.update(hexToBytes(txHash));
|
|
1804
|
+
for (const witness of witnesses) {
|
|
1805
|
+
const bytes = hexToBytes(witness);
|
|
1806
|
+
hasher.update(hexToBytes(toUint64Le(BigInt(bytes.length))));
|
|
1807
|
+
hasher.update(bytes);
|
|
1808
|
+
}
|
|
1809
|
+
return bytesToHex(hasher.digest("binary"));
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function toRpcTransaction(transaction) {
|
|
1813
|
+
return {
|
|
1814
|
+
version: transaction.version,
|
|
1815
|
+
cell_deps: transaction.cellDeps.map((dep) => ({
|
|
1816
|
+
out_point: {
|
|
1817
|
+
tx_hash: dep.outPoint.txHash,
|
|
1818
|
+
index: dep.outPoint.index,
|
|
1819
|
+
},
|
|
1820
|
+
dep_type: dep.depType === "depGroup" ? "dep_group" : dep.depType,
|
|
1821
|
+
})),
|
|
1822
|
+
header_deps: transaction.headerDeps,
|
|
1823
|
+
inputs: transaction.inputs.map((input) => ({
|
|
1824
|
+
since: input.since,
|
|
1825
|
+
previous_output: {
|
|
1826
|
+
tx_hash: input.previousOutput.txHash,
|
|
1827
|
+
index: input.previousOutput.index,
|
|
1828
|
+
},
|
|
1829
|
+
})),
|
|
1830
|
+
outputs: transaction.outputs.map((output) => ({
|
|
1831
|
+
capacity: output.capacity,
|
|
1832
|
+
lock: toRpcScript(output.lock),
|
|
1833
|
+
type: output.type,
|
|
1834
|
+
})),
|
|
1835
|
+
outputs_data: transaction.outputsData,
|
|
1836
|
+
witnesses: transaction.witnesses,
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
function toRpcScript(script) {
|
|
1841
|
+
return {
|
|
1842
|
+
code_hash: script.codeHash ?? script.code_hash,
|
|
1843
|
+
hash_type: script.hashType ?? script.hash_type,
|
|
1844
|
+
args: script.args,
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function secpCellDepOutPoint(chain) {
|
|
1849
|
+
return String(chain).toLowerCase() === "mainnet"
|
|
1850
|
+
? systemScripts.SECP256K1_BLAKE160.mainnetOutPoint
|
|
1851
|
+
: systemScripts.SECP256K1_BLAKE160.testnetOutPoint;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function normalizeOutPoint(value) {
|
|
1855
|
+
if (!value || typeof value !== "object") return undefined;
|
|
1856
|
+
const txHash = textValue(value.tx_hash ?? value.txHash);
|
|
1857
|
+
const index = textValue(value.index);
|
|
1858
|
+
return txHash && index ? { txHash, index } : undefined;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function paymentIntentRecipients(params) {
|
|
1862
|
+
const rawRecipients = Array.isArray(params.recipients) ? params.recipients.slice(0, 12) : [];
|
|
1863
|
+
const fallbackTargetType = textValue(params.targetType).toLowerCase() === "wallet" ? "wallet" : "node";
|
|
1864
|
+
const fallback = {
|
|
1865
|
+
targetType: fallbackTargetType,
|
|
1866
|
+
recipientAddress: textValue(params.recipientAddress) || undefined,
|
|
1867
|
+
amount: textValue(params.amount) || undefined,
|
|
1868
|
+
};
|
|
1869
|
+
const recipients = rawRecipients.length ? rawRecipients : [fallback];
|
|
1870
|
+
return recipients.map((item) => {
|
|
1871
|
+
const record = item && typeof item === "object" ? item : {};
|
|
1872
|
+
const targetType = textValue(record.targetType).toLowerCase() === "wallet" ? "wallet" : "node";
|
|
1873
|
+
const recipientAddress = textValue(record.recipientAddress ?? record.address) || undefined;
|
|
1874
|
+
const amount = textValue(record.amount) || fallback.amount;
|
|
1875
|
+
return {
|
|
1876
|
+
targetType,
|
|
1877
|
+
recipientAddress,
|
|
1878
|
+
amount,
|
|
1879
|
+
};
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function totalRecipientAmount(recipients) {
|
|
1884
|
+
const shannons = recipients.reduce((total, recipient) => total + paymentAmountToShannons(recipient.amount), 0n);
|
|
1885
|
+
if (shannons <= 0n) return "";
|
|
1886
|
+
const whole = shannons / 100000000n;
|
|
1887
|
+
const fraction = (shannons % 100000000n).toString().padStart(8, "0").replace(/0+$/, "");
|
|
1888
|
+
return fraction ? `${whole}.${fraction}` : String(whole);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function paymentAmountToShannons(value) {
|
|
1892
|
+
const text = textValue(value);
|
|
1893
|
+
if (!/^[0-9]+(?:\.[0-9]{1,8})?$/.test(text)) return 0n;
|
|
1894
|
+
const [whole, fraction = ""] = text.split(".");
|
|
1895
|
+
return BigInt(whole) * 100000000n + BigInt(fraction.padEnd(8, "0"));
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
async function postCommandResult(config, body) {
|
|
1899
|
+
const resultUrl = config.commandResultUrl ?? config.reportUrl.replace(/\/report\/?$/, "/command-result");
|
|
1900
|
+
let response;
|
|
1901
|
+
try {
|
|
1902
|
+
response = await fetch(resultUrl, {
|
|
1903
|
+
method: "POST",
|
|
1904
|
+
headers: {
|
|
1905
|
+
"content-type": "application/json",
|
|
1906
|
+
"x-agent-token": config.agentToken,
|
|
1907
|
+
},
|
|
1908
|
+
body: stringify(body),
|
|
1909
|
+
});
|
|
1910
|
+
} catch (error) {
|
|
1911
|
+
throw new Error(`Command result failed at ${resultUrl}: ${message(error)}`);
|
|
1912
|
+
}
|
|
1913
|
+
if (!response.ok) {
|
|
1914
|
+
const payload = parsePayload(await response.text());
|
|
1915
|
+
throw new Error(`Command result failed: ${describeAgentHttpError(payload, `HTTP_${response.status}`)}`);
|
|
1916
|
+
}
|
|
1917
|
+
return parsePayload(await response.text());
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
async function openFiberChannel(config, params) {
|
|
1921
|
+
const peer = resolveCommandPeer(params);
|
|
1922
|
+
const pubkey = normalizePeerPubkey(peer.pubkey);
|
|
1923
|
+
await assertNotLocalPeer(config, pubkey);
|
|
1924
|
+
const openRequest = {
|
|
1925
|
+
pubkey,
|
|
1926
|
+
funding_amount: fundingAmount(params),
|
|
1927
|
+
public: boolValue(params.public ?? params.publicChannel, true),
|
|
1928
|
+
};
|
|
1929
|
+
if (params.oneWay !== undefined || params.one_way !== undefined) openRequest.one_way = boolValue(params.oneWay ?? params.one_way, false);
|
|
1930
|
+
if (params.fundingUdtTypeScript ?? params.funding_udt_type_script) openRequest.funding_udt_type_script = params.fundingUdtTypeScript ?? params.funding_udt_type_script;
|
|
1931
|
+
if (params.commitmentFeeRate ?? params.commitment_fee_rate) openRequest.commitment_fee_rate = quantity(params.commitmentFeeRate ?? params.commitment_fee_rate);
|
|
1932
|
+
if (params.fundingFeeRate ?? params.funding_fee_rate) openRequest.funding_fee_rate = quantity(params.fundingFeeRate ?? params.funding_fee_rate);
|
|
1933
|
+
if (params.tlcExpiryDelta ?? params.tlc_expiry_delta) openRequest.tlc_expiry_delta = quantity(params.tlcExpiryDelta ?? params.tlc_expiry_delta);
|
|
1934
|
+
if (params.tlcMinimumValue ?? params.tlc_minimum_value) openRequest.tlc_minimum_value = quantity(params.tlcMinimumValue ?? params.tlc_minimum_value);
|
|
1935
|
+
if (params.tlcFeeProportionalMillionths ?? params.tlc_fee_proportional_millionths) {
|
|
1936
|
+
openRequest.tlc_fee_proportional_millionths = quantity(params.tlcFeeProportionalMillionths ?? params.tlc_fee_proportional_millionths);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const fundingCheck = await assertChannelFundingAvailable(config, openRequest.funding_amount);
|
|
1940
|
+
let connectResult;
|
|
1941
|
+
const connectFirst = boolValue(params.connectFirst, true) && !boolValue(params.skipConnect, false);
|
|
1942
|
+
if (connectFirst) {
|
|
1943
|
+
connectResult = await ensurePeerConnected(config, peer, pubkey);
|
|
1944
|
+
}
|
|
1945
|
+
const openResult = await openChannelWhenPeerReady(config, peer, pubkey, openRequest, params);
|
|
1946
|
+
let channelWatch;
|
|
1947
|
+
if (!boolValue(params.wait, false) && !boolValue(params.waitUntilReady, false)) {
|
|
1948
|
+
channelWatch = await watchForImmediateChannelFailure(
|
|
1949
|
+
config.fiberRpcUrl,
|
|
1950
|
+
pubkey,
|
|
1951
|
+
positiveNumber(params.failureWatchMs ?? config.channelFailureWatchMs, DEFAULT_CHANNEL_FAILURE_WATCH_MS),
|
|
1952
|
+
positiveNumber(params.failurePollMs ?? params.pollMs ?? config.channelFailurePollMs, DEFAULT_CHANNEL_FAILURE_POLL_MS),
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
const result = {
|
|
1956
|
+
ok: true,
|
|
1957
|
+
action: "OPEN_CHANNEL",
|
|
1958
|
+
peerPubkey: pubkey,
|
|
1959
|
+
peer: peer.meta,
|
|
1960
|
+
peerAddress: peer.address,
|
|
1961
|
+
fundingAmount: openRequest.funding_amount,
|
|
1962
|
+
public: openRequest.public,
|
|
1963
|
+
fundingCheck,
|
|
1964
|
+
connectResult,
|
|
1965
|
+
openResult,
|
|
1966
|
+
channelWatch,
|
|
1967
|
+
};
|
|
1968
|
+
if (boolValue(params.wait, false) || boolValue(params.waitUntilReady, false)) {
|
|
1969
|
+
result.channel = await waitForChannel(config.fiberRpcUrl, pubkey, positiveNumber(params.waitTimeoutMs, 20 * 60_000), positiveNumber(params.pollMs, 10_000));
|
|
1970
|
+
}
|
|
1971
|
+
return result;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
async function settleFiberChannel(runtime, params) {
|
|
1975
|
+
const config = runtime.config ?? runtime;
|
|
1976
|
+
const channelId = firstTextValue(params.channelId, params.channel_id, params.id);
|
|
1977
|
+
if (!channelId) throw new Error("Channel settlement requires a channel id.");
|
|
1978
|
+
|
|
1979
|
+
const info = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs);
|
|
1980
|
+
const closeScript = params.closeScript ?? params.close_script ?? info?.default_funding_lock_script;
|
|
1981
|
+
if (!closeScript || typeof closeScript !== "object") {
|
|
1982
|
+
throw new Error("Fiber did not report a default funding lock script for channel settlement.");
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const force = boolValue(params.force, false);
|
|
1986
|
+
const feeRate = params.feeRate ?? params.fee_rate ?? "1000";
|
|
1987
|
+
const before = await findChannelById(config.fiberRpcUrl, channelId, config.timeoutMs).catch(() => undefined);
|
|
1988
|
+
const request = force
|
|
1989
|
+
? { channel_id: channelId, force: true }
|
|
1990
|
+
: {
|
|
1991
|
+
channel_id: channelId,
|
|
1992
|
+
close_script: closeScript,
|
|
1993
|
+
fee_rate: quantity(feeRate),
|
|
1994
|
+
force: false,
|
|
1995
|
+
};
|
|
1996
|
+
const result = await rpcCall(config.fiberRpcUrl, "shutdown_channel", [request], config.timeoutMs);
|
|
1997
|
+
const after = await findChannelById(config.fiberRpcUrl, channelId, config.timeoutMs).catch(() => undefined);
|
|
1998
|
+
|
|
1999
|
+
let refreshed;
|
|
2000
|
+
if (typeof runtime.collect === "function" && typeof runtime.push === "function") {
|
|
2001
|
+
refreshed = await pushWithRegistrationRetry(runtime, await runtime.collect(), {
|
|
2002
|
+
intent: "channel-settlement",
|
|
2003
|
+
retryOnRateLimit: true,
|
|
2004
|
+
maxRateLimitRetries: 1,
|
|
2005
|
+
}).catch((error) => ({ ok: false, error: message(error) }));
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
return {
|
|
2009
|
+
ok: true,
|
|
2010
|
+
action: "SETTLE_CHANNEL",
|
|
2011
|
+
mode: "Close channel to funding wallet",
|
|
2012
|
+
message: "Fiber does not expose a splice-out or withdraw-channel RPC on this node. Issued shutdown_channel; when the close transaction confirms, this node's local channel balance returns to the funding wallet lock.",
|
|
2013
|
+
channelId,
|
|
2014
|
+
settlement: {
|
|
2015
|
+
settlementMethod: "shutdown_channel",
|
|
2016
|
+
spliceOutSupported: false,
|
|
2017
|
+
withdrawSupported: false,
|
|
2018
|
+
closeToFundingWallet: true,
|
|
2019
|
+
force,
|
|
2020
|
+
feeRate: force ? undefined : quantity(feeRate),
|
|
2021
|
+
closeScript: force ? undefined : closeScript,
|
|
2022
|
+
},
|
|
2023
|
+
before,
|
|
2024
|
+
after,
|
|
2025
|
+
result,
|
|
2026
|
+
refresh: refreshed,
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
async function findChannelById(rpcUrl, channelId, timeoutMs) {
|
|
2031
|
+
const response = await rpcCall(rpcUrl, "list_channels", [{ include_closed: true }], timeoutMs);
|
|
2032
|
+
const channels = Array.isArray(response?.channels) ? response.channels : Array.isArray(response) ? response : [];
|
|
2033
|
+
const normalized = channelId.toLowerCase();
|
|
2034
|
+
return channels.find((channel) => textValue(channel?.channel_id ?? channel?.channelId ?? channel?.id).toLowerCase() === normalized);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
async function assertNotLocalPeer(config, pubkey) {
|
|
2038
|
+
try {
|
|
2039
|
+
const info = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs);
|
|
2040
|
+
const localPubkey = normalizePeerPubkey(readNodeId(info));
|
|
2041
|
+
if (localPubkey && peerKey(localPubkey) === peerKey(pubkey)) {
|
|
2042
|
+
throw new Error("Cannot connect a Fiber node to itself. Use a peer token from a different node.");
|
|
2043
|
+
}
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
if (message(error).includes("Cannot connect a Fiber node to itself")) throw error;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
async function ensurePeerConnected(config, peer, pubkey) {
|
|
2050
|
+
let lookupWarning;
|
|
2051
|
+
let knownPeer;
|
|
2052
|
+
try {
|
|
2053
|
+
knownPeer = await findPeerByPubkey(config, pubkey);
|
|
2054
|
+
} catch (error) {
|
|
2055
|
+
lookupWarning = message(error);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
if (knownPeer?.connected) {
|
|
2059
|
+
return {
|
|
2060
|
+
skipped: true,
|
|
2061
|
+
reason: "Peer is already connected.",
|
|
2062
|
+
peer: knownPeer,
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const address = peer.address || knownPeer?.address;
|
|
2067
|
+
if (!address && !knownPeer?.connected) {
|
|
2068
|
+
throw new Error(`Peer address is required to connect ${shortText(pubkey, 10, 8)}. Paste the peer multiaddr or choose a public node that advertises a dial address.`);
|
|
2069
|
+
}
|
|
2070
|
+
try {
|
|
2071
|
+
const result = await rpcCall(config.fiberRpcUrl, "connect_peer", [omitUndefined({ pubkey, address })], config.timeoutMs);
|
|
2072
|
+
return {
|
|
2073
|
+
skipped: false,
|
|
2074
|
+
deduped: Boolean(knownPeer),
|
|
2075
|
+
peer: knownPeer,
|
|
2076
|
+
lookupWarning,
|
|
2077
|
+
result,
|
|
2078
|
+
};
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
const warning = message(error);
|
|
2081
|
+
if (isPeerAlreadyConnectedError(warning)) {
|
|
2082
|
+
return {
|
|
2083
|
+
skipped: true,
|
|
2084
|
+
reason: "Fiber RPC reported that this peer is already connected.",
|
|
2085
|
+
peer: knownPeer,
|
|
2086
|
+
warning,
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
if (!address && peer.meta?.source === "fibscope-token") {
|
|
2090
|
+
throw new Error("Fibscope peer token is missing a dial address. Ask the peer to restart their agent with the latest code, wait for a fresh report, then copy a new peer token.");
|
|
2091
|
+
}
|
|
2092
|
+
if (knownPeer) {
|
|
2093
|
+
throw new Error(`Peer ${shortText(pubkey, 10, 8)} is known but could not be reconnected: ${warning}`);
|
|
2094
|
+
}
|
|
2095
|
+
throw error;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
async function assertChannelFundingAvailable(config, requestedAmount) {
|
|
2100
|
+
if (boolValue(config.skipChannelFundingPreflight, false)) {
|
|
2101
|
+
return { skipped: true, reason: "Channel funding preflight disabled." };
|
|
2102
|
+
}
|
|
2103
|
+
const funding = await collectFundingState(config);
|
|
2104
|
+
const wallet = await collectFundingWalletState(config, funding);
|
|
2105
|
+
const requested = amountBigInt(requestedAmount);
|
|
2106
|
+
const reserve = channelFundingReserve(config);
|
|
2107
|
+
const required = requested + reserve;
|
|
2108
|
+
const available = amountBigInt(funding.availableCapacity);
|
|
2109
|
+
const walletStatus = String(wallet.status ?? "").toLowerCase();
|
|
2110
|
+
|
|
2111
|
+
if (walletStatus !== "linked") {
|
|
2112
|
+
throw new Error(channelWalletNotReadyMessage(wallet));
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if (String(funding.status ?? "").toLowerCase() !== "ready") {
|
|
2116
|
+
throw new Error(funding.error
|
|
2117
|
+
? `Funding balance unavailable. ${funding.error}`
|
|
2118
|
+
: "Funding balance unavailable. The agent could not verify spendable node wallet capacity, so channel creation was stopped.");
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
if (available < required) {
|
|
2122
|
+
throw new Error(`Insufficient funding balance. Opening this channel needs about ${ckbText(required)} (${ckbText(requested)} channel capacity plus ${ckbText(reserve)} fee reserve), but this node wallet only has ${ckbText(available)} spendable. Fund the node wallet or lower the channel amount.`);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
return {
|
|
2126
|
+
ok: true,
|
|
2127
|
+
source: funding.source,
|
|
2128
|
+
checkedAt: funding.checkedAt,
|
|
2129
|
+
requestedCapacity: requested.toString(),
|
|
2130
|
+
feeReserve: reserve.toString(),
|
|
2131
|
+
requiredCapacity: required.toString(),
|
|
2132
|
+
availableCapacity: available.toString(),
|
|
2133
|
+
walletStatus: wallet.status,
|
|
2134
|
+
walletAddress: wallet.address,
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function channelWalletNotReadyMessage(wallet) {
|
|
2139
|
+
const status = String(wallet?.status ?? "missing").toLowerCase();
|
|
2140
|
+
if (status === "missing") return "Funding wallet is not linked. Create or link a local funding wallet before opening a channel.";
|
|
2141
|
+
if (status === "pending_restart") return "Funding wallet is saved, but FNN is still using another key. Restart FNN before opening a channel.";
|
|
2142
|
+
if (status === "invalid") return `Funding wallet is invalid. ${wallet?.error ? `${wallet.error}` : "Repair or replace it in Agent Setup before opening a channel."}`;
|
|
2143
|
+
if (status === "unavailable") return `Funding wallet status is unavailable. ${wallet?.error ? `${wallet.error}` : "Start or bind the agent to the correct FNN instance."}`;
|
|
2144
|
+
return `Funding wallet is not ready (${status}). Set up the local funding wallet before opening a channel.`;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
async function openChannelWhenPeerReady(config, peer, pubkey, openRequest, params) {
|
|
2148
|
+
const timeoutMs = positiveNumber(params.openTimeoutMs ?? params.peerReadyTimeoutMs ?? config.channelOpenTimeoutMs ?? config.peerReadyTimeoutMs, DEFAULT_CHANNEL_OPEN_TIMEOUT_MS);
|
|
2149
|
+
const retryMs = positiveNumber(params.openRetryMs ?? params.peerReadyPollMs ?? config.channelOpenRetryMs ?? config.peerReadyPollMs, DEFAULT_CHANNEL_OPEN_RETRY_MS);
|
|
2150
|
+
const startedAt = Date.now();
|
|
2151
|
+
const deadline = Date.now() + timeoutMs;
|
|
2152
|
+
let attempts = 0;
|
|
2153
|
+
let lastPeer;
|
|
2154
|
+
let lastError;
|
|
2155
|
+
let announcedWait = false;
|
|
2156
|
+
|
|
2157
|
+
while (Date.now() <= deadline) {
|
|
2158
|
+
attempts += 1;
|
|
2159
|
+
try {
|
|
2160
|
+
const result = await rpcCall(config.fiberRpcUrl, "open_channel", [openRequest], config.timeoutMs);
|
|
2161
|
+
return {
|
|
2162
|
+
...asObject(result),
|
|
2163
|
+
attempts,
|
|
2164
|
+
peerReadyWaitMs: Math.max(0, Date.now() - startedAt),
|
|
2165
|
+
};
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
const detail = message(error);
|
|
2168
|
+
if (isInsufficientFundingError(detail)) {
|
|
2169
|
+
throw new Error(`Channel funding failed: insufficient node wallet capacity. ${cleanFundingFailureText(detail)}`);
|
|
2170
|
+
}
|
|
2171
|
+
if (!isPeerFeaturePendingError(detail)) throw error;
|
|
2172
|
+
lastError = detail;
|
|
2173
|
+
if (!announcedWait) {
|
|
2174
|
+
console.log(formatCliBlock({
|
|
2175
|
+
title: "Fiber handshake",
|
|
2176
|
+
status: "waiting",
|
|
2177
|
+
tone: "info",
|
|
2178
|
+
rows: [
|
|
2179
|
+
["Peer", shortText(pubkey, 10, 8)],
|
|
2180
|
+
["Timeout", `${timeoutMs} ms`],
|
|
2181
|
+
["Retry", `${retryMs} ms`],
|
|
2182
|
+
["Reason", "FNN has not received the peer Init/features message yet."],
|
|
2183
|
+
],
|
|
2184
|
+
}));
|
|
2185
|
+
announcedWait = true;
|
|
2186
|
+
}
|
|
2187
|
+
lastPeer = await findPeerByPubkey(config, pubkey).catch(() => lastPeer);
|
|
2188
|
+
if (Date.now() + retryMs > deadline) break;
|
|
2189
|
+
if (!lastPeer && peer.address) {
|
|
2190
|
+
await ensurePeerConnected(config, peer, pubkey).catch(() => undefined);
|
|
2191
|
+
}
|
|
2192
|
+
await sleep(retryMs);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
const peerSummary = lastPeer
|
|
2197
|
+
? `Last peer state: ${JSON.stringify(lastPeer)}`
|
|
2198
|
+
: "The peer never appeared in list_peers during the wait window.";
|
|
2199
|
+
throw new Error(`Peer connected, but Fiber handshake is not ready yet. FNN is still waiting for the peer Init/features message for ${shortText(pubkey, 10, 8)} after ${timeoutMs}ms. ${peerSummary} Last RPC error: ${lastError ?? "none"}`);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
async function findPeerByPubkey(config, pubkey) {
|
|
2203
|
+
const raw = await rpcCall(config.fiberRpcUrl, "list_peers", [], config.timeoutMs);
|
|
2204
|
+
const normalizedPubkey = peerKey(pubkey);
|
|
2205
|
+
return extractPeerList(raw)
|
|
2206
|
+
.map(normalizePeer)
|
|
2207
|
+
.find((peer) => peerKey(peer.peerId) === normalizedPubkey || peerKey(peer.pubkey) === normalizedPubkey);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function extractPeerList(raw) {
|
|
2211
|
+
if (Array.isArray(raw)) return raw;
|
|
2212
|
+
if (!raw || typeof raw !== "object") return [];
|
|
2213
|
+
for (const key of ["peers", "peer_states", "items", "list"]) {
|
|
2214
|
+
const value = raw[key];
|
|
2215
|
+
if (Array.isArray(value)) return value;
|
|
2216
|
+
}
|
|
2217
|
+
return [];
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function normalizePeer(peer) {
|
|
2221
|
+
const status = textValue(peer?.status ?? peer?.state ?? peer?.connection_state);
|
|
2222
|
+
return {
|
|
2223
|
+
peerId: textValue(peer?.peerId ?? peer?.peer_id ?? peer?.nodeId ?? peer?.node_id ?? peer?.id ?? peer?.pubkey ?? peer?.public_key),
|
|
2224
|
+
pubkey: textValue(peer?.pubkey ?? peer?.public_key ?? peer?.nodeId ?? peer?.node_id),
|
|
2225
|
+
connected: booleanMaybe(peer?.connected ?? peer?.is_connected ?? peer?.online) ?? (status ? peerStatusConnected(status) : true),
|
|
2226
|
+
address: textValue(peer?.address ?? peer?.addr ?? peer?.multiaddr ?? peer?.multi_addr) || firstString(peer?.addresses),
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function peerKey(value) {
|
|
2231
|
+
return String(value ?? "").trim().replace(/^0x/i, "").toLowerCase();
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function booleanMaybe(value) {
|
|
2235
|
+
if (typeof value === "boolean") return value;
|
|
2236
|
+
if (typeof value === "string") {
|
|
2237
|
+
const normalized = value.trim().toLowerCase();
|
|
2238
|
+
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
|
2239
|
+
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
|
2240
|
+
}
|
|
2241
|
+
return undefined;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function peerStatusConnected(value) {
|
|
2245
|
+
return ["connected", "online", "active", "established", "ready"].includes(String(value ?? "").trim().toLowerCase());
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function isPeerAlreadyConnectedError(value) {
|
|
2249
|
+
return /already\s+(connected|exists|known)|duplicate\s+peer|peer\s+.*connected/i.test(String(value ?? ""));
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function isPeerFeaturePendingError(value) {
|
|
2253
|
+
return /feature\s+not\s+found|waiting\s+for\s+peer\s+to\s+send\s+init\s+message/i.test(String(value ?? ""));
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function asObject(value) {
|
|
2257
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : { value };
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
function firstString(value) {
|
|
2261
|
+
if (!Array.isArray(value)) return "";
|
|
2262
|
+
return textValue(value.find((item) => typeof item === "string" && item.trim()));
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
async function loadConfig(configPath, options = {}) {
|
|
2266
|
+
let config;
|
|
2267
|
+
let label = configPath;
|
|
2268
|
+
if (!existsSync(configPath) && hasEnvConfig()) {
|
|
2269
|
+
config = {
|
|
2270
|
+
pushIntervalMs: Number(process.env.FIBSCOPE_AGENT_PUSH_INTERVAL_MS ?? DEFAULT_PUSH_INTERVAL_MS),
|
|
2271
|
+
commandPollMs: Number(process.env.FIBSCOPE_AGENT_COMMAND_POLL_MS ?? DEFAULT_COMMAND_POLL_MS),
|
|
2272
|
+
knownPeerReconnectMs: Number(process.env.FIBSCOPE_AGENT_KNOWN_PEER_RECONNECT_MS ?? DEFAULT_KNOWN_PEER_RECONNECT_MS),
|
|
2273
|
+
publicProfileEnabled: yes(process.env.FIBSCOPE_AGENT_PUBLIC_PROFILE),
|
|
2274
|
+
overwriteNodeRegistration: yes(process.env.FIBSCOPE_AGENT_OVERWRITE_NODE_REGISTRATION),
|
|
2275
|
+
publicFields: {},
|
|
2276
|
+
chain: process.env.FIBSCOPE_AGENT_CHAIN ?? "testnet",
|
|
2277
|
+
reportUrl: resolveReportUrl({ reportUrl: configuredReportUrl() }),
|
|
2278
|
+
agentToken: envValue(ENV_AGENT_TOKEN, ENV_AGENT_TOKEN_ALIAS),
|
|
2279
|
+
fiberRpcUrl: process.env.FIBSCOPE_AGENT_FIBER_RPC_URL,
|
|
2280
|
+
ckbRpcUrl: process.env.FIBSCOPE_AGENT_CKB_RPC_URL ?? envValue(ENV_CKB_RPC_URL),
|
|
2281
|
+
ckbIndexerUrl: process.env.FIBSCOPE_AGENT_CKB_INDEXER_URL,
|
|
2282
|
+
walletPath: process.env.FIBSCOPE_AGENT_WALLET_PATH ?? envValue(ENV_WALLET_PATH),
|
|
2283
|
+
peerAddress: process.env.FIBSCOPE_AGENT_PEER_ADDRESS,
|
|
2284
|
+
};
|
|
2285
|
+
label = "agent/.env";
|
|
2286
|
+
} else if (!existsSync(configPath)) {
|
|
2287
|
+
throw new Error(`Missing agent config at ${configPath}. Run: npm run agent:init`);
|
|
2288
|
+
} else {
|
|
2289
|
+
config = await readJson(configPath);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
return normalizeConfig(await bindAgentConfigToFnn(config, label, options), label);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function configuredReportUrl(config = {}) {
|
|
2296
|
+
return textValue(config.reportUrl) || envValue(ENV_REPORT_URL, ENV_REPORT_URL_ALIAS);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function resolveReportUrl(config = {}) {
|
|
2300
|
+
return configuredReportUrl(config) || DEFAULT_REPORT_URL;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
async function fetchAgentConfig(config) {
|
|
2304
|
+
const configUrl = config.configUrl ?? config.reportUrl.replace(/\/report\/?$/, "/config");
|
|
2305
|
+
const response = await fetch(configUrl, {
|
|
2306
|
+
headers: {
|
|
2307
|
+
"x-agent-token": config.agentToken,
|
|
2308
|
+
},
|
|
2309
|
+
});
|
|
2310
|
+
const text = await response.text();
|
|
2311
|
+
const payload = parsePayload(text);
|
|
2312
|
+
if (!response.ok) throw new Error(`Agent config unavailable: ${describeAgentHttpError(payload, `HTTP_${response.status}`)}`);
|
|
2313
|
+
return payload;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function mergeAgentRemoteConfig(previous, next) {
|
|
2317
|
+
if (!previous) return next;
|
|
2318
|
+
if (!next || typeof next !== "object") return previous;
|
|
2319
|
+
const previousNode = previous?.node && typeof previous.node === "object" ? previous.node : {};
|
|
2320
|
+
const nextNode = next?.node && typeof next.node === "object" ? next.node : {};
|
|
2321
|
+
const knownPeers = Array.isArray(nextNode.knownPeers) && nextNode.knownPeers.length
|
|
2322
|
+
? nextNode.knownPeers
|
|
2323
|
+
: Array.isArray(previousNode.knownPeers) ? previousNode.knownPeers : [];
|
|
2324
|
+
return {
|
|
2325
|
+
...previous,
|
|
2326
|
+
...next,
|
|
2327
|
+
node: {
|
|
2328
|
+
...previousNode,
|
|
2329
|
+
...nextNode,
|
|
2330
|
+
knownPeers,
|
|
2331
|
+
},
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
async function registerAgentNode(config, report, remoteConfig) {
|
|
2336
|
+
const localNodeId = readNodeId(report.node);
|
|
2337
|
+
const remoteNodeId = remoteConfig?.node?.nodeId;
|
|
2338
|
+
const registerUrl = config.registerUrl ?? config.reportUrl.replace(/\/report\/?$/, "/register");
|
|
2339
|
+
const response = await fetch(registerUrl, {
|
|
2340
|
+
method: "POST",
|
|
2341
|
+
headers: {
|
|
2342
|
+
"content-type": "application/json",
|
|
2343
|
+
"x-agent-token": config.agentToken,
|
|
2344
|
+
},
|
|
2345
|
+
body: stringify({
|
|
2346
|
+
agentVersion: report.agentVersion,
|
|
2347
|
+
chain: report.chain ?? config.chain,
|
|
2348
|
+
collectedAt: report.collectedAt,
|
|
2349
|
+
nodeId: localNodeId ?? remoteNodeId,
|
|
2350
|
+
fiberNodeId: localNodeId,
|
|
2351
|
+
displayName: config.nodeDisplayName ?? remoteConfig?.node?.displayName,
|
|
2352
|
+
publicProfileEnabled: Boolean(report.publicProfileEnabled),
|
|
2353
|
+
overwrite: Boolean(config.overwriteNodeRegistration),
|
|
2354
|
+
node: report.node ?? {},
|
|
2355
|
+
}),
|
|
2356
|
+
});
|
|
2357
|
+
const text = await response.text();
|
|
2358
|
+
const payload = parsePayload(text);
|
|
2359
|
+
if (!response.ok) {
|
|
2360
|
+
throw new Error(`Agent registration failed: ${describeAgentHttpError(payload, `HTTP_${response.status}`)}`);
|
|
2361
|
+
}
|
|
2362
|
+
return payload;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
async function reconnectKnownPeersFromGateway(config, remoteConfig, options = {}) {
|
|
2366
|
+
const candidates = Array.isArray(remoteConfig?.node?.knownPeers)
|
|
2367
|
+
? remoteConfig.node.knownPeers.map(normalizeKnownPeerCandidate).filter((peer) => Boolean(peer?.pubkey && peer?.address))
|
|
2368
|
+
: [];
|
|
2369
|
+
if (!candidates.length) {
|
|
2370
|
+
return { attempted: 0, reconnected: 0, skipped: [{ reason: "no-known-peers" }] };
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
const localInfo = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs).catch(() => ({}));
|
|
2374
|
+
const localPubkey = peerKey(readNodeId(localInfo));
|
|
2375
|
+
const currentPeers = extractPeerList(await rpcCall(config.fiberRpcUrl, "list_peers", [], config.timeoutMs)).map(normalizePeer);
|
|
2376
|
+
const connected = new Set(currentPeers.filter((peer) => peer.connected).map((peer) => peerKey(peer.pubkey || peer.peerId)));
|
|
2377
|
+
|
|
2378
|
+
const result = {
|
|
2379
|
+
attempted: 0,
|
|
2380
|
+
reconnected: 0,
|
|
2381
|
+
skipped: [],
|
|
2382
|
+
failed: [],
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
for (const candidate of candidates) {
|
|
2386
|
+
const pubkey = peerKey(candidate.pubkey);
|
|
2387
|
+
if (!pubkey || !candidate.address) continue;
|
|
2388
|
+
if (localPubkey && pubkey === localPubkey) {
|
|
2389
|
+
result.skipped.push({ pubkey: candidate.pubkey, reason: "self" });
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
if (connected.has(pubkey)) {
|
|
2393
|
+
result.skipped.push({ pubkey: candidate.pubkey, reason: "already-connected" });
|
|
2394
|
+
continue;
|
|
2395
|
+
}
|
|
2396
|
+
result.attempted += 1;
|
|
2397
|
+
try {
|
|
2398
|
+
await rpcCall(config.fiberRpcUrl, "connect_peer", [omitUndefined({ pubkey: candidate.pubkey, address: candidate.address })], config.timeoutMs);
|
|
2399
|
+
connected.add(pubkey);
|
|
2400
|
+
result.reconnected += 1;
|
|
2401
|
+
} catch (error) {
|
|
2402
|
+
const detail = message(error);
|
|
2403
|
+
if (isPeerAlreadyConnectedError(detail)) {
|
|
2404
|
+
connected.add(pubkey);
|
|
2405
|
+
result.skipped.push({ pubkey: candidate.pubkey, reason: "already-connected" });
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
result.failed.push({ pubkey: candidate.pubkey, reason: detail });
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
return result;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function normalizeKnownPeerCandidate(value) {
|
|
2416
|
+
if (!value || typeof value !== "object") return null;
|
|
2417
|
+
return {
|
|
2418
|
+
id: textValue(value.id),
|
|
2419
|
+
pubkey: textValue(value.pubkey ?? value.peerId ?? value.peer_id ?? value.nodeId ?? value.node_id ?? value.id),
|
|
2420
|
+
address: textValue(value.address ?? value.addr ?? value.multiaddr ?? value.multi_addr) || firstString(value.addresses),
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
function parsePayload(text) {
|
|
2425
|
+
if (!text) return {};
|
|
2426
|
+
try {
|
|
2427
|
+
return JSON.parse(text);
|
|
2428
|
+
} catch {
|
|
2429
|
+
return { message: text };
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function payloadCode(payload) {
|
|
2434
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
2435
|
+
const code = payload.code ?? payload.error;
|
|
2436
|
+
return typeof code === "string" ? code : undefined;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function retryAfterToMs(value) {
|
|
2440
|
+
const text = String(value ?? "").trim();
|
|
2441
|
+
if (!text) return undefined;
|
|
2442
|
+
const seconds = Number(text);
|
|
2443
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000);
|
|
2444
|
+
const dateMs = Date.parse(text);
|
|
2445
|
+
return Number.isFinite(dateMs) ? Math.max(0, dateMs - Date.now()) : undefined;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function readCommandId(command) {
|
|
2449
|
+
const id = command && typeof command === "object" ? command.commandId ?? command.id : undefined;
|
|
2450
|
+
if (typeof id === "string" && id.trim()) return id.trim();
|
|
2451
|
+
throw new Error("Command is missing an id.");
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function describeAgentHttpError(payload, fallback) {
|
|
2455
|
+
const code = payloadCode(payload) ?? fallback;
|
|
2456
|
+
const detail = payload && typeof payload === "object" && typeof payload.message === "string" ? payload.message : "";
|
|
2457
|
+
if (fallback === "HTTP_429" || code === "RATE_LIMITED" || code === "Rate Limited") {
|
|
2458
|
+
return "Rate limited: Fibscope is already accepting a recent report for this node. The agent will retry shortly when this is a manual refresh.";
|
|
2459
|
+
}
|
|
2460
|
+
if (code === "NODE_REGISTRATION_REQUIRED") {
|
|
2461
|
+
const node = payload && typeof payload === "object" && typeof payload.nodeId === "string" ? ` (${payload.nodeId})` : "";
|
|
2462
|
+
return `NODE_REGISTRATION_REQUIRED${node}: register this Fiber node with the API key before reports can be accepted.`;
|
|
2463
|
+
}
|
|
2464
|
+
if (code === "NODE_REPLACEMENT_REQUIRES_OVERWRITE") {
|
|
2465
|
+
return "NODE_REPLACEMENT_REQUIRES_OVERWRITE: this API key is already bound to another registered Fiber node. Set FIBSCOPE_AGENT_OVERWRITE_NODE_REGISTRATION=true only if you want this node to replace it.";
|
|
2466
|
+
}
|
|
2467
|
+
if (code === "NODE_ALREADY_REGISTERED") {
|
|
2468
|
+
return "NODE_ALREADY_REGISTERED: this Fiber node is already owned by another account or API key.";
|
|
2469
|
+
}
|
|
2470
|
+
if (code === "AGENT_TOKEN_INVALID" || code === "AGENT_TOKEN_REQUIRED") {
|
|
2471
|
+
return `${code}: create a fresh agent token from Agent Setup and restart the agent with ${ENV_AGENT_TOKEN}.`;
|
|
2472
|
+
}
|
|
2473
|
+
return detail && detail !== code ? `${code}: ${detail}` : code;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function normalizeConfig(config, label) {
|
|
2477
|
+
const normalized = {
|
|
2478
|
+
pushIntervalMs: DEFAULT_PUSH_INTERVAL_MS,
|
|
2479
|
+
commandPollMs: DEFAULT_COMMAND_POLL_MS,
|
|
2480
|
+
knownPeerReconnectMs: DEFAULT_KNOWN_PEER_RECONNECT_MS,
|
|
2481
|
+
publicProfileEnabled: false,
|
|
2482
|
+
overwriteNodeRegistration: false,
|
|
2483
|
+
publicFields: {},
|
|
2484
|
+
chain: "testnet",
|
|
2485
|
+
...config,
|
|
2486
|
+
reportUrl: resolveReportUrl(config),
|
|
2487
|
+
agentToken: textValue(config.agentToken) || envValue(ENV_AGENT_TOKEN, ENV_AGENT_TOKEN_ALIAS),
|
|
2488
|
+
ckbRpcUrl: process.env.FIBSCOPE_AGENT_CKB_RPC_URL ?? envValue(ENV_CKB_RPC_URL) ?? config.ckbRpcUrl,
|
|
2489
|
+
ckbIndexerUrl: process.env.FIBSCOPE_AGENT_CKB_INDEXER_URL ?? config.ckbIndexerUrl,
|
|
2490
|
+
walletPath: process.env.FIBSCOPE_AGENT_WALLET_PATH ?? envValue(ENV_WALLET_PATH) ?? config.walletPath,
|
|
2491
|
+
pushIntervalMs: positiveNumber(config.pushIntervalMs, DEFAULT_PUSH_INTERVAL_MS),
|
|
2492
|
+
commandPollMs: positiveNumber(config.commandPollMs, DEFAULT_COMMAND_POLL_MS),
|
|
2493
|
+
knownPeerReconnectMs: positiveNumber(config.knownPeerReconnectMs, DEFAULT_KNOWN_PEER_RECONNECT_MS),
|
|
2494
|
+
};
|
|
2495
|
+
const required = ["reportUrl", "agentToken", "fiberRpcUrl"];
|
|
2496
|
+
for (const field of required) {
|
|
2497
|
+
if (!normalized[field]) throw new Error(`Missing ${field} in ${label}`);
|
|
2498
|
+
}
|
|
2499
|
+
return normalized;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
async function bindAgentConfigToFnn(config, label, options = {}) {
|
|
2503
|
+
const next = { ...config };
|
|
2504
|
+
if (options.fiberRpcUrl) {
|
|
2505
|
+
next.fiberRpcUrl = options.fiberRpcUrl;
|
|
2506
|
+
return enrichConfigFromMatchingFnn(next);
|
|
2507
|
+
}
|
|
2508
|
+
if (process.env.FIBSCOPE_AGENT_FIBER_RPC_URL) {
|
|
2509
|
+
next.fiberRpcUrl = process.env.FIBSCOPE_AGENT_FIBER_RPC_URL;
|
|
2510
|
+
return enrichConfigFromMatchingFnn(next);
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
if (options.noBindFnn) return enrichConfigFromMatchingFnn(next);
|
|
2514
|
+
if (next.fiberRpcUrl && await rpcResponds(next.fiberRpcUrl, 1200)) return enrichConfigFromMatchingFnn(next);
|
|
2515
|
+
|
|
2516
|
+
const selected = await discoverAgentFnnInstance(next.fiberRpcUrl);
|
|
2517
|
+
if (!selected?.rpcUrl) return enrichConfigFromMatchingFnn(next);
|
|
2518
|
+
|
|
2519
|
+
next.fiberRpcUrl = selected.rpcUrl;
|
|
2520
|
+
next.ckbRpcUrl ??= selected.ckbRpcUrl;
|
|
2521
|
+
next.fnnBaseDir ??= selected.baseDir;
|
|
2522
|
+
next.fnnConfigPath ??= selected.configPath;
|
|
2523
|
+
next.fnnP2pListenAddr ??= selected.p2pListenAddr;
|
|
2524
|
+
next.chain ??= selected.network;
|
|
2525
|
+
console.log(`Bound agent to running FNN: ${describeFnnInstance(selected)}`);
|
|
2526
|
+
return applyFnnMatchToAgentConfig(next, selected);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
async function enrichConfigFromMatchingFnn(config) {
|
|
2530
|
+
if (!config.fiberRpcUrl) return config;
|
|
2531
|
+
try {
|
|
2532
|
+
const instances = await discoverFnnInstances({ timeoutMs: 800 });
|
|
2533
|
+
const match = instances.find((instance) => sameRpcUrl(instance.rpcUrl, config.fiberRpcUrl));
|
|
2534
|
+
if (!match) return config;
|
|
2535
|
+
return applyFnnMatchToAgentConfig(config, match);
|
|
2536
|
+
} catch {
|
|
2537
|
+
return config;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
function applyFnnMatchToAgentConfig(config, match) {
|
|
2542
|
+
const peerAddress = normalizeLocalListenAddress(match?.p2pListenAddr);
|
|
2543
|
+
const hasConfiguredPeerAddress = configuredPeerAddresses(config).length > 0;
|
|
2544
|
+
return {
|
|
2545
|
+
...config,
|
|
2546
|
+
ckbRpcUrl: config.ckbRpcUrl ?? match.ckbRpcUrl,
|
|
2547
|
+
fnnBaseDir: config.fnnBaseDir ?? match.baseDir,
|
|
2548
|
+
fnnConfigPath: config.fnnConfigPath ?? match.configPath,
|
|
2549
|
+
fnnP2pListenAddr: config.fnnP2pListenAddr ?? match.p2pListenAddr,
|
|
2550
|
+
...(!hasConfiguredPeerAddress && peerAddress ? { peerAddress } : {}),
|
|
2551
|
+
chain: config.chain ?? match.network,
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
async function discoverAgentRpcUrl(currentRpcUrl, options = {}) {
|
|
2556
|
+
if (currentRpcUrl && await rpcResponds(currentRpcUrl, 1200)) return currentRpcUrl;
|
|
2557
|
+
const selected = await discoverAgentFnnInstance(currentRpcUrl, options);
|
|
2558
|
+
return selected?.rpcUrl;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
async function discoverAgentFnnInstance(currentRpcUrl, options = {}) {
|
|
2562
|
+
const instances = await discoverFnnInstances({ timeoutMs: 1200 });
|
|
2563
|
+
return chooseFnnInstance(instances, {
|
|
2564
|
+
currentRpcUrl,
|
|
2565
|
+
interactive: process.stdin.isTTY && process.stdout.isTTY,
|
|
2566
|
+
prompt: options.prompt ?? promptLine,
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
async function writeStatus(statusPath, patch) {
|
|
2571
|
+
let current = {};
|
|
2572
|
+
if (existsSync(statusPath)) {
|
|
2573
|
+
try {
|
|
2574
|
+
current = await readJson(statusPath);
|
|
2575
|
+
} catch (error) {
|
|
2576
|
+
current = {
|
|
2577
|
+
previousStatusUnreadable: true,
|
|
2578
|
+
previousStatusError: message(error),
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
await writeJson(statusPath, { ...current, ...patch, updatedAt: Date.now() });
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
async function prompt(terminal, label, fallback) {
|
|
2586
|
+
const answer = await terminal.question(`${label}${fallback ? ` [${fallback}]` : ""}: `);
|
|
2587
|
+
return answer.trim() || fallback;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function readNodeId(node) {
|
|
2591
|
+
if (!node || typeof node !== "object") return undefined;
|
|
2592
|
+
for (const key of ["nodeId", "node_id", "peerId", "peer_id", "id", "pubkey", "public_key"]) {
|
|
2593
|
+
const value = node[key];
|
|
2594
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
2595
|
+
}
|
|
2596
|
+
return undefined;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function normalizePeerPubkey(value) {
|
|
2600
|
+
const text = String(value ?? "").trim().replace(/^0x/i, "");
|
|
2601
|
+
if (!/^[0-9a-fA-F]{66}$/.test(text)) throw new Error("Peer pubkey must be a 33-byte compressed public key.");
|
|
2602
|
+
return text;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
function resolveCommandPeer(params) {
|
|
2606
|
+
const peerToken = firstTextValue(params.peerToken, params.fibscopePeerToken);
|
|
2607
|
+
if (peerToken) {
|
|
2608
|
+
const token = decodeFibscopePeerToken(peerToken);
|
|
2609
|
+
return {
|
|
2610
|
+
pubkey: token.pubkey,
|
|
2611
|
+
address: token.address,
|
|
2612
|
+
meta: {
|
|
2613
|
+
source: "fibscope-token",
|
|
2614
|
+
nodeId: token.nodeId,
|
|
2615
|
+
displayName: token.displayName,
|
|
2616
|
+
chain: token.chain,
|
|
2617
|
+
address: token.address,
|
|
2618
|
+
},
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
const pubkey = firstTextValue(params.peerPubkey, params.selectedPeerPubkey, params.pubkey, params.peer);
|
|
2622
|
+
return {
|
|
2623
|
+
pubkey,
|
|
2624
|
+
address: firstTextValue(params.peerAddress, params.address),
|
|
2625
|
+
meta: {
|
|
2626
|
+
source: params.mode === "online" ? "online-peer" : "advanced-pubkey",
|
|
2627
|
+
},
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
function decodeFibscopePeerToken(value) {
|
|
2632
|
+
const text = String(value ?? "").replace(/\s+/g, "").trim();
|
|
2633
|
+
const encoded = text.startsWith("fibpeer_") ? text.slice("fibpeer_".length) : text;
|
|
2634
|
+
if (!encoded) throw new Error("Fibscope peer token is empty.");
|
|
2635
|
+
let payload;
|
|
2636
|
+
try {
|
|
2637
|
+
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
2638
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
2639
|
+
payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
|
2640
|
+
} catch {
|
|
2641
|
+
throw new Error("Fibscope peer token is invalid.");
|
|
2642
|
+
}
|
|
2643
|
+
if (!payload || typeof payload !== "object") throw new Error("Fibscope peer token is invalid.");
|
|
2644
|
+
if (payload.platform && payload.platform !== "fibscope") throw new Error("Fibscope peer token was created for another platform.");
|
|
2645
|
+
const pubkey = textValue(payload.pubkey ?? payload.peerPubkey ?? payload.fiberNodeId);
|
|
2646
|
+
if (!pubkey) throw new Error("Fibscope peer token is missing a peer pubkey.");
|
|
2647
|
+
const addresses = readAddressList(payload.addresses ?? payload.peerAddresses);
|
|
2648
|
+
const address = textValue(payload.address ?? payload.peerAddress) || addresses[0];
|
|
2649
|
+
return {
|
|
2650
|
+
pubkey,
|
|
2651
|
+
address,
|
|
2652
|
+
addresses,
|
|
2653
|
+
nodeId: textValue(payload.nodeId),
|
|
2654
|
+
displayName: textValue(payload.displayName),
|
|
2655
|
+
chain: textValue(payload.chain),
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function fundingAmount(params) {
|
|
2660
|
+
if (params.ckb !== undefined && params.ckb !== "") return ckbToShannonsHex(params.ckb);
|
|
2661
|
+
const value = params.fundingAmount ?? params.funding_amount ?? params.amount;
|
|
2662
|
+
if (value !== undefined && value !== "") return quantity(value);
|
|
2663
|
+
return ckbToShannonsHex(DEFAULT_CHANNEL_FUNDING_CKB);
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
function ckbToShannonsHex(value) {
|
|
2667
|
+
return `0x${ckbToShannons(value).toString(16)}`;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
function ckbToShannons(value) {
|
|
2671
|
+
const text = String(value).trim();
|
|
2672
|
+
const match = text.match(/^([0-9]+)(?:\.([0-9]{1,8}))?$/);
|
|
2673
|
+
if (!match) throw new Error("Funding amount must be a decimal CKB value with up to 8 fractional digits.");
|
|
2674
|
+
const whole = BigInt(match[1]) * 100_000_000n;
|
|
2675
|
+
const fraction = BigInt((match[2] ?? "").padEnd(8, "0"));
|
|
2676
|
+
return whole + fraction;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function quantity(value) {
|
|
2680
|
+
const text = String(value).trim();
|
|
2681
|
+
if (/^0x[0-9a-fA-F]+$/.test(text)) return text.toLowerCase();
|
|
2682
|
+
if (/^[0-9]+$/.test(text)) return `0x${BigInt(text).toString(16)}`;
|
|
2683
|
+
throw new Error(`Invalid integer quantity: ${text}`);
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
function textValue(value) {
|
|
2687
|
+
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function firstTextValue(...values) {
|
|
2691
|
+
for (const value of values) {
|
|
2692
|
+
const text = textValue(value);
|
|
2693
|
+
if (text) return text;
|
|
2694
|
+
}
|
|
2695
|
+
return "";
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function readAddressList(value) {
|
|
2699
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim());
|
|
2700
|
+
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
2701
|
+
return [];
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function uniqueStrings(values) {
|
|
2705
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim()))];
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function stripAnsi(value) {
|
|
2709
|
+
return String(value ?? "").replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function omitUndefined(value) {
|
|
2713
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined && entry !== ""));
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
function boolValue(value, fallback) {
|
|
2717
|
+
if (typeof value === "boolean") return value;
|
|
2718
|
+
if (typeof value === "string") {
|
|
2719
|
+
if (["true", "1", "yes", "y", "on"].includes(value.trim().toLowerCase())) return true;
|
|
2720
|
+
if (["false", "0", "no", "n", "off"].includes(value.trim().toLowerCase())) return false;
|
|
2721
|
+
}
|
|
2722
|
+
return fallback;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
function positiveNumber(value, fallback) {
|
|
2726
|
+
const number = Number(value);
|
|
2727
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
async function rpcCall(rpcUrl, method, params = [], timeoutMs = 10_000) {
|
|
2731
|
+
const controller = new AbortController();
|
|
2732
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2733
|
+
try {
|
|
2734
|
+
const response = await fetch(rpcUrl, {
|
|
2735
|
+
method: "POST",
|
|
2736
|
+
headers: { "content-type": "application/json" },
|
|
2737
|
+
body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }),
|
|
2738
|
+
signal: controller.signal,
|
|
2739
|
+
});
|
|
2740
|
+
const text = await response.text();
|
|
2741
|
+
let payload;
|
|
2742
|
+
try {
|
|
2743
|
+
payload = JSON.parse(text);
|
|
2744
|
+
} catch {
|
|
2745
|
+
throw new Error(`RPC ${method} returned non-JSON response: HTTP ${response.status} ${text.slice(0, 200)}`);
|
|
2746
|
+
}
|
|
2747
|
+
if (!response.ok) throw new Error(`RPC ${method} HTTP ${response.status}: ${text.slice(0, 300)}`);
|
|
2748
|
+
if (payload.error) throw new Error(`RPC ${method} failed: ${payload.error.message ?? "unknown error"}${formatRpcErrorData(payload.error.data)}`);
|
|
2749
|
+
return payload.result;
|
|
2750
|
+
} finally {
|
|
2751
|
+
clearTimeout(timer);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
function formatRpcErrorData(data) {
|
|
2756
|
+
if (data === undefined || data === null || data === "") return "";
|
|
2757
|
+
if (typeof data === "string") return ` (${data})`;
|
|
2758
|
+
try {
|
|
2759
|
+
return ` (${JSON.stringify(data)})`;
|
|
2760
|
+
} catch {
|
|
2761
|
+
return ` (${String(data)})`;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
async function waitForChannel(rpcUrl, pubkey, timeoutMs, pollMs) {
|
|
2766
|
+
const deadline = Date.now() + timeoutMs;
|
|
2767
|
+
let lastChannel;
|
|
2768
|
+
while (Date.now() < deadline) {
|
|
2769
|
+
const response = await rpcCall(rpcUrl, "list_channels", [{ pubkey, include_closed: true }]);
|
|
2770
|
+
const channels = Array.isArray(response?.channels) ? response.channels : Array.isArray(response) ? response : [];
|
|
2771
|
+
lastChannel = channels[0] ?? lastChannel;
|
|
2772
|
+
const ready = channels.find((channel) => channelStateName(channel) === "ChannelReady" || channelStateName(channel).toLowerCase() === "open");
|
|
2773
|
+
if (ready) return ready;
|
|
2774
|
+
const failed = channels.find((channel) => ["Closed", "Abandoned", "Failed"].includes(channelStateName(channel)));
|
|
2775
|
+
if (failed) throw new Error(describeChannelTerminalFailure(failed));
|
|
2776
|
+
await sleep(pollMs);
|
|
2777
|
+
}
|
|
2778
|
+
throw new Error(`Timed out waiting for channel readiness. Last channel: ${JSON.stringify(lastChannel ?? null)}`);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
async function watchForImmediateChannelFailure(rpcUrl, pubkey, timeoutMs, pollMs) {
|
|
2782
|
+
if (timeoutMs <= 0) return { skipped: true, reason: "Immediate channel failure watch disabled." };
|
|
2783
|
+
const deadline = Date.now() + timeoutMs;
|
|
2784
|
+
let lastChannel;
|
|
2785
|
+
while (Date.now() < deadline) {
|
|
2786
|
+
const response = await rpcCall(rpcUrl, "list_channels", [{ pubkey, include_closed: true }]);
|
|
2787
|
+
const channels = Array.isArray(response?.channels) ? response.channels : Array.isArray(response) ? response : [];
|
|
2788
|
+
lastChannel = channels[0] ?? lastChannel;
|
|
2789
|
+
const failed = channels.find((channel) => ["Closed", "Abandoned", "Failed"].includes(channelStateName(channel)));
|
|
2790
|
+
if (failed) throw new Error(describeChannelTerminalFailure(failed));
|
|
2791
|
+
const ready = channels.find((channel) => channelStateName(channel) === "ChannelReady" || channelStateName(channel).toLowerCase() === "open");
|
|
2792
|
+
if (ready) return { status: "ready", channel: ready };
|
|
2793
|
+
await sleep(pollMs);
|
|
2794
|
+
}
|
|
2795
|
+
return {
|
|
2796
|
+
status: "pending",
|
|
2797
|
+
watchedMs: timeoutMs,
|
|
2798
|
+
lastChannelState: lastChannel ? channelStateName(lastChannel) : "none",
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
function channelStateName(channel) {
|
|
2803
|
+
const state = channel?.state;
|
|
2804
|
+
if (!state) return String(channel?.status ?? channel?.state_name ?? "Unknown");
|
|
2805
|
+
if (typeof state === "string") return state;
|
|
2806
|
+
if (state.state_name) return String(state.state_name);
|
|
2807
|
+
const [variant] = Object.keys(state).filter((key) => key !== "flags");
|
|
2808
|
+
return variant ?? "Unknown";
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
function describeChannelTerminalFailure(channel) {
|
|
2812
|
+
const state = channelStateName(channel);
|
|
2813
|
+
const detail = channelFailureDetail(channel);
|
|
2814
|
+
const normalizedDetail = cleanFundingFailureText(detail);
|
|
2815
|
+
if (isInsufficientFundingError(normalizedDetail)) {
|
|
2816
|
+
return `Channel funding failed: insufficient node wallet capacity. ${normalizedDetail}`;
|
|
2817
|
+
}
|
|
2818
|
+
return `Channel entered terminal state ${state}${normalizedDetail ? `: ${normalizedDetail}` : "."}`;
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
function channelFailureDetail(channel) {
|
|
2822
|
+
for (const key of ["failure_detail", "failureDetail", "failure_reason", "failureReason", "reason", "error", "message"]) {
|
|
2823
|
+
const value = channel?.[key];
|
|
2824
|
+
if (value === undefined || value === null || value === "") continue;
|
|
2825
|
+
return typeof value === "string" ? value : stringify(value);
|
|
2826
|
+
}
|
|
2827
|
+
const state = channel?.state;
|
|
2828
|
+
if (state && typeof state === "object") {
|
|
2829
|
+
for (const value of Object.values(state)) {
|
|
2830
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
2831
|
+
if (value && typeof value === "object" && Object.keys(value).length) return stringify(value);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
return "";
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function cleanFundingFailureText(value) {
|
|
2838
|
+
return String(value ?? "").replace(/`+/g, "").replace(/\s+/g, " ").trim();
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function isInsufficientFundingError(value) {
|
|
2842
|
+
return /capacity\s+not\s+enough|balance\s+capacity\s+error|insufficient/i.test(String(value ?? ""));
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
function buildProgress({ chain, collectedAt, state, funding, observability, recommendations }) {
|
|
2846
|
+
const peers = state.peers ?? [];
|
|
2847
|
+
const channels = state.channels ?? [];
|
|
2848
|
+
const diagnostics = state.diagnostics ?? [];
|
|
2849
|
+
const alerts = observability.alerts ?? [];
|
|
2850
|
+
const health = observability.health ?? state.health ?? {};
|
|
2851
|
+
const online = Boolean(state.node?.online);
|
|
2852
|
+
const connectedPeers = peers.filter((peer) => peer?.connected).length;
|
|
2853
|
+
const openChannels = channels.filter((channel) => channel?.status === "open").length;
|
|
2854
|
+
const critical = diagnostics.find((item) => item?.severity === "critical");
|
|
2855
|
+
const warning = diagnostics.find((item) => item?.severity === "warning");
|
|
2856
|
+
const topIssue = critical ?? warning ?? diagnostics[0];
|
|
2857
|
+
return {
|
|
2858
|
+
source: "agent",
|
|
2859
|
+
status: online && health?.overall?.status !== "critical" ? "active" : online ? "degraded" : "unavailable",
|
|
2860
|
+
chain,
|
|
2861
|
+
collectedAt,
|
|
2862
|
+
nodeId: readNodeId(state.node),
|
|
2863
|
+
online,
|
|
2864
|
+
health: {
|
|
2865
|
+
score: numeric(health?.overall?.score, 0),
|
|
2866
|
+
status: text(health?.overall?.status, online ? "healthy" : "critical"),
|
|
2867
|
+
},
|
|
2868
|
+
peers: {
|
|
2869
|
+
total: peers.length,
|
|
2870
|
+
connected: connectedPeers,
|
|
2871
|
+
offline: Math.max(0, peers.length - connectedPeers),
|
|
2872
|
+
},
|
|
2873
|
+
channels: {
|
|
2874
|
+
total: channels.length,
|
|
2875
|
+
open: openChannels,
|
|
2876
|
+
},
|
|
2877
|
+
liquidity: {
|
|
2878
|
+
outbound: amountString(state.liquidity?.totalOutbound),
|
|
2879
|
+
inbound: amountString(state.liquidity?.totalInbound),
|
|
2880
|
+
channelCount: numeric(state.liquidity?.channelCount, openChannels),
|
|
2881
|
+
},
|
|
2882
|
+
funding: {
|
|
2883
|
+
status: text(funding?.status, "unavailable"),
|
|
2884
|
+
availableCapacity: amountString(funding?.availableCapacity),
|
|
2885
|
+
totalCapacity: amountString(funding?.totalCapacity),
|
|
2886
|
+
cellCount: numeric(funding?.cellCount, 0),
|
|
2887
|
+
checkedAt: numeric(funding?.checkedAt, collectedAt),
|
|
2888
|
+
},
|
|
2889
|
+
diagnostics: {
|
|
2890
|
+
total: diagnostics.length,
|
|
2891
|
+
critical: diagnostics.filter((item) => item?.severity === "critical").length,
|
|
2892
|
+
warning: diagnostics.filter((item) => item?.severity === "warning").length,
|
|
2893
|
+
topIssue: topIssue ? {
|
|
2894
|
+
code: text(topIssue.code, "UNKNOWN"),
|
|
2895
|
+
severity: text(topIssue.severity, "info"),
|
|
2896
|
+
title: text(topIssue.title, "Status update"),
|
|
2897
|
+
} : null,
|
|
2898
|
+
},
|
|
2899
|
+
alerts: {
|
|
2900
|
+
total: alerts.length,
|
|
2901
|
+
unread: alerts.length,
|
|
2902
|
+
},
|
|
2903
|
+
recommendations: {
|
|
2904
|
+
total: recommendations.length,
|
|
2905
|
+
top: recommendations[0] ? {
|
|
2906
|
+
priority: numeric(recommendations[0].priority, 0),
|
|
2907
|
+
title: text(recommendations[0].title, "Recommendation"),
|
|
2908
|
+
actionType: text(recommendations[0].actionType, "ACTION"),
|
|
2909
|
+
} : null,
|
|
2910
|
+
},
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
function deriveProgressEvents(previousReport, current) {
|
|
2915
|
+
const events = [];
|
|
2916
|
+
const previousOnline = previousReport?.progress?.online ?? previousReport?.node?.online;
|
|
2917
|
+
if (previousReport && previousOnline !== current.progress.online) {
|
|
2918
|
+
events.push({
|
|
2919
|
+
id: stableEventId(current.progress.online ? "node-online" : "node-offline", current.progress.nodeId, current.collectedAt),
|
|
2920
|
+
timestamp: current.collectedAt,
|
|
2921
|
+
category: "node",
|
|
2922
|
+
type: current.progress.online ? "NODE_ONLINE" : "NODE_OFFLINE",
|
|
2923
|
+
visibility: "public",
|
|
2924
|
+
payload: {
|
|
2925
|
+
chain: current.chain,
|
|
2926
|
+
nodeId: current.progress.nodeId,
|
|
2927
|
+
online: current.progress.online,
|
|
2928
|
+
},
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
const previousIssue = previousReport?.progress?.diagnostics?.topIssue?.code;
|
|
2932
|
+
const currentIssue = current.progress.diagnostics?.topIssue?.code;
|
|
2933
|
+
if (previousReport && currentIssue && currentIssue !== previousIssue) {
|
|
2934
|
+
events.push({
|
|
2935
|
+
id: stableEventId("node-issue", current.progress.nodeId, currentIssue, current.collectedAt),
|
|
2936
|
+
timestamp: current.collectedAt,
|
|
2937
|
+
category: "diagnostic",
|
|
2938
|
+
type: "NODE_ISSUE_CHANGED",
|
|
2939
|
+
visibility: "private",
|
|
2940
|
+
payload: {
|
|
2941
|
+
chain: current.chain,
|
|
2942
|
+
nodeId: current.progress.nodeId,
|
|
2943
|
+
issue: current.progress.diagnostics.topIssue,
|
|
2944
|
+
},
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
return events;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
function stableEventId(...parts) {
|
|
2951
|
+
return parts
|
|
2952
|
+
.filter((part) => part !== undefined && part !== null && part !== "")
|
|
2953
|
+
.map((part) => String(part).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""))
|
|
2954
|
+
.filter(Boolean)
|
|
2955
|
+
.join("-")
|
|
2956
|
+
.slice(0, 160);
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function amountString(value) {
|
|
2960
|
+
if (typeof value === "bigint") return value.toString();
|
|
2961
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(Math.trunc(value));
|
|
2962
|
+
if (typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value.trim())) return BigInt(value).toString();
|
|
2963
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
2964
|
+
return "0";
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function ckbText(value) {
|
|
2968
|
+
const shannons = amountBigInt(value);
|
|
2969
|
+
const whole = shannons / 100_000_000n;
|
|
2970
|
+
const fraction = shannons % 100_000_000n;
|
|
2971
|
+
if (fraction === 0n) return `${whole.toLocaleString("en-US")} CKB`;
|
|
2972
|
+
const fractionText = fraction.toString().padStart(8, "0").replace(/0+$/, "");
|
|
2973
|
+
return `${whole.toLocaleString("en-US")}.${fractionText} CKB`;
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
function numeric(value, fallback) {
|
|
2977
|
+
const number = Number(value);
|
|
2978
|
+
return Number.isFinite(number) ? number : fallback;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
function text(value, fallback) {
|
|
2982
|
+
const string = String(value ?? "").trim();
|
|
2983
|
+
return string || fallback;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
function nextBackoff(current) {
|
|
2987
|
+
return Math.min(MAX_BACKOFF_MS, Math.max(1_000, current * 2));
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
function sleep(ms) {
|
|
2991
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function yes(value) {
|
|
2995
|
+
return ["y", "yes", "true", "1", "on"].includes(String(value).trim().toLowerCase());
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
async function readJson(path) {
|
|
2999
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
async function writeJson(path, value) {
|
|
3003
|
+
await mkdir(dirname(path), { recursive: true });
|
|
3004
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
function stringify(value) {
|
|
3008
|
+
return JSON.stringify(value, (_key, nested) => typeof nested === "bigint" ? nested.toString() : nested);
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
function message(error) {
|
|
3012
|
+
return error instanceof Error ? error.message : String(error);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
function formatAgentStarted(runtime, remoteNode) {
|
|
3016
|
+
return formatCliBlock({
|
|
3017
|
+
title: "Fibscope agent",
|
|
3018
|
+
status: "running",
|
|
3019
|
+
tone: "success",
|
|
3020
|
+
rows: [
|
|
3021
|
+
["Config", runtime.configPath],
|
|
3022
|
+
["Fiber RPC", runtime.config.fiberRpcUrl],
|
|
3023
|
+
["Fibscope", runtime.config.reportUrl],
|
|
3024
|
+
["Node", remoteNode ?? "registration pending"],
|
|
3025
|
+
["Push", `${runtime.config.pushIntervalMs} ms`],
|
|
3026
|
+
["Commands", `${runtime.config.commandPollMs} ms`],
|
|
3027
|
+
],
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function formatFnnPreflightError(rpcUrl, detail) {
|
|
3032
|
+
return formatCliBlock({
|
|
3033
|
+
title: "Fibscope agent",
|
|
3034
|
+
status: "FNN unreachable",
|
|
3035
|
+
tone: "error",
|
|
3036
|
+
rows: [
|
|
3037
|
+
["Fiber RPC", rpcUrl],
|
|
3038
|
+
["Probe", "node_info"],
|
|
3039
|
+
["Result", detail],
|
|
3040
|
+
["Next", "Start the matching FNN instance on this port, then run the agent again."],
|
|
3041
|
+
],
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
function formatCommandEvent(status, command, config, tone, detail = undefined) {
|
|
3046
|
+
const params = command?.params && typeof command.params === "object" ? command.params : {};
|
|
3047
|
+
const rows = [
|
|
3048
|
+
["Command", String(command?.command ?? "unknown")],
|
|
3049
|
+
["ID", shortText(command?.id ?? command?.commandId, 8, 4)],
|
|
3050
|
+
["Node", shortText(command?.nodeId, 9, 6)],
|
|
3051
|
+
["Fiber RPC", config.fiberRpcUrl],
|
|
3052
|
+
];
|
|
3053
|
+
const peer = commandPeerSummary(params);
|
|
3054
|
+
if (peer) rows.push(["Peer", peer]);
|
|
3055
|
+
const amount = commandAmountSummary(params);
|
|
3056
|
+
if (amount) rows.push(["Amount", amount]);
|
|
3057
|
+
if (detail) rows.push(["Result", detail]);
|
|
3058
|
+
return formatCliBlock({
|
|
3059
|
+
title: "Agent command",
|
|
3060
|
+
status,
|
|
3061
|
+
tone,
|
|
3062
|
+
rows,
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
function commandPeerSummary(params) {
|
|
3067
|
+
if (textValue(params.peerToken ?? params.fibscopePeerToken)) return "Fibscope token";
|
|
3068
|
+
const peer = textValue(params.peerPubkey ?? params.selectedPeerPubkey ?? params.pubkey ?? params.peer);
|
|
3069
|
+
return peer ? shortText(peer, 10, 8) : "";
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
function commandAmountSummary(params) {
|
|
3073
|
+
if (params.ckb !== undefined && params.ckb !== "") return `${params.ckb} CKB`;
|
|
3074
|
+
const value = params.fundingAmount ?? params.funding_amount ?? params.amount;
|
|
3075
|
+
return value !== undefined && value !== "" ? String(value) : "";
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
function shortText(value, start = 8, end = 4) {
|
|
3079
|
+
const text = String(value ?? "").trim();
|
|
3080
|
+
if (!text) return "";
|
|
3081
|
+
if (text.length <= start + end + 3) return text;
|
|
3082
|
+
return `${text.slice(0, start)}...${text.slice(-end)}`;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
function formatCliBlock({ title, status, tone, rows }) {
|
|
3086
|
+
const width = Math.max(8, ...rows.map(([label]) => label.length));
|
|
3087
|
+
const statusColor = tone === "success" ? green : tone === "error" ? red : cyan;
|
|
3088
|
+
const lines = [
|
|
3089
|
+
`${bold(cyan(title))} ${statusColor(status)}`,
|
|
3090
|
+
...rows.map(([label, value]) => ` ${dim(label.padEnd(width))} ${valueColor(tone, String(value ?? ""))}`),
|
|
3091
|
+
];
|
|
3092
|
+
return lines.join("\n");
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function valueColor(tone, value) {
|
|
3096
|
+
if (!value) return dim("-");
|
|
3097
|
+
if (tone === "error") return value.includes("Start ") ? yellow(value) : value;
|
|
3098
|
+
return value.startsWith("http://") || value.startsWith("https://") ? cyan(value) : value;
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
function bold(value) {
|
|
3102
|
+
return paint(1, value);
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
function dim(value) {
|
|
3106
|
+
return paint(2, value);
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
function red(value) {
|
|
3110
|
+
return paint(31, value);
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
function green(value) {
|
|
3114
|
+
return paint(32, value);
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
function yellow(value) {
|
|
3118
|
+
return paint(33, value);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
function cyan(value) {
|
|
3122
|
+
return paint(36, value);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
function paint(code, value) {
|
|
3126
|
+
if (process.env.NO_COLOR || process.env.FIBSCOPE_NO_COLOR) return String(value);
|
|
3127
|
+
return `\x1b[${code}m${value}\x1b[0m`;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
function loadAgentEnv() {
|
|
3131
|
+
for (const envPath of AGENT_ENV_PATHS) {
|
|
3132
|
+
if (!existsSync(envPath)) continue;
|
|
3133
|
+
const lines = readFileSync(envPath, "utf8").split(/\r?\n/);
|
|
3134
|
+
for (const line of lines) applyEnvLine(line);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
function applyEnvLine(line) {
|
|
3139
|
+
const trimmed = line.trim();
|
|
3140
|
+
if (!trimmed || trimmed.startsWith("#")) return;
|
|
3141
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed);
|
|
3142
|
+
if (!match) return;
|
|
3143
|
+
const [, key, rawValue] = match;
|
|
3144
|
+
if (process.env[key] !== undefined) return;
|
|
3145
|
+
process.env[key] = normalizeEnvValue(rawValue);
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
function normalizeEnvValue(rawValue) {
|
|
3149
|
+
let value = rawValue.trim();
|
|
3150
|
+
if (
|
|
3151
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
3152
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
3153
|
+
) {
|
|
3154
|
+
return value.slice(1, -1);
|
|
3155
|
+
}
|
|
3156
|
+
const commentIndex = value.indexOf(" #");
|
|
3157
|
+
if (commentIndex >= 0) value = value.slice(0, commentIndex).trim();
|
|
3158
|
+
return value;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function hasEnvConfig() {
|
|
3162
|
+
return Boolean(
|
|
3163
|
+
envValue(ENV_REPORT_URL, ENV_REPORT_URL_ALIAS) ||
|
|
3164
|
+
envValue(ENV_AGENT_TOKEN, ENV_AGENT_TOKEN_ALIAS) ||
|
|
3165
|
+
process.env.FIBSCOPE_AGENT_FIBER_RPC_URL,
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
function envValue(...keys) {
|
|
3170
|
+
for (const key of keys) {
|
|
3171
|
+
const value = process.env[key];
|
|
3172
|
+
if (value !== undefined && String(value).trim()) return value;
|
|
3173
|
+
}
|
|
3174
|
+
return undefined;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
function parseArgs(argv) {
|
|
3178
|
+
if (argv[0] === "--help" || argv[0] === "-h") return { command: "help", options: {} };
|
|
3179
|
+
const command = argv[0] && !argv[0].startsWith("--") ? argv.shift() : "status";
|
|
3180
|
+
const options = {};
|
|
3181
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
3182
|
+
const raw = argv[index];
|
|
3183
|
+
const [flag, inline] = raw.split("=", 2);
|
|
3184
|
+
const value = () => inline ?? argv[++index];
|
|
3185
|
+
if (flag === "--config") options.config = value();
|
|
3186
|
+
else if (flag === "--status") options.status = value();
|
|
3187
|
+
else if (flag === "--fiber-rpc-url") options.fiberRpcUrl = value();
|
|
3188
|
+
else if (flag === "--no-bind-fnn") options.noBindFnn = true;
|
|
3189
|
+
else if (flag === "--skip-fnn-preflight") options.skipFnnPreflight = true;
|
|
3190
|
+
else if (flag === "--help" || flag === "-h") options.help = true;
|
|
3191
|
+
else throw new Error(`Unknown option ${flag}`);
|
|
3192
|
+
}
|
|
3193
|
+
return { command, options };
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
async function help() {
|
|
3197
|
+
console.log([
|
|
3198
|
+
"Fibscope Agent for Fiber node owners.",
|
|
3199
|
+
"",
|
|
3200
|
+
"Usage:",
|
|
3201
|
+
" agent init [--config .fiber/agent.json]",
|
|
3202
|
+
" agent start [--config .fiber/agent.json]",
|
|
3203
|
+
" agent once [--config .fiber/agent.json]",
|
|
3204
|
+
" agent status [--status .fiber/agent-status.json]",
|
|
3205
|
+
"",
|
|
3206
|
+
"Options:",
|
|
3207
|
+
" --config <path> Agent config path. Default: .fiber/agent.json",
|
|
3208
|
+
" --status <path> Status file path. Default: .fiber/agent-status.json",
|
|
3209
|
+
" --fiber-rpc-url <url> Override local FNN RPC URL",
|
|
3210
|
+
" --no-bind-fnn Do not auto-bind to a running FNN process",
|
|
3211
|
+
" --skip-fnn-preflight Skip FNN RPC readiness check",
|
|
3212
|
+
" --help Show this help",
|
|
3213
|
+
"",
|
|
3214
|
+
"Config sources:",
|
|
3215
|
+
" agent/.env",
|
|
3216
|
+
" .env",
|
|
3217
|
+
" .fiber/agent.json",
|
|
3218
|
+
].join("\n"));
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
async function promptLine(label) {
|
|
3222
|
+
const terminal = createInterface({ input, output });
|
|
3223
|
+
try {
|
|
3224
|
+
return await terminal.question(label);
|
|
3225
|
+
} finally {
|
|
3226
|
+
terminal.close();
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
3231
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
3232
|
+
const commands = { init, start, once, status, help };
|
|
3233
|
+
if (!commands[command]) {
|
|
3234
|
+
console.error(`Unknown command ${command}. Use init, start, once, status, or help.`);
|
|
3235
|
+
process.exitCode = 1;
|
|
3236
|
+
} else if (options.help) {
|
|
3237
|
+
help();
|
|
3238
|
+
} else {
|
|
3239
|
+
commands[command](options).catch((error) => {
|
|
3240
|
+
console.error(message(error));
|
|
3241
|
+
process.exitCode = 1;
|
|
3242
|
+
});
|
|
3243
|
+
}
|
|
3244
|
+
}
|