@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.
@@ -0,0 +1,1053 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { pathToFileURL } from "node:url";
4
+ import path from "node:path";
5
+
6
+ export class FiberRouteVirtualizerError extends Error {
7
+ constructor(message, code = "FIBER_ROUTE_VIRTUALIZER_ERROR", details = undefined) {
8
+ super(message);
9
+ this.name = "FiberRouteVirtualizerError";
10
+ this.code = code;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ export async function virtualizePayment(request, options = {}) {
16
+ const normalized = normalizePaymentRequest(request);
17
+ const componentState = await resolveComponentState(options);
18
+ const readinessPrediction = predictComponentReadinessFailure(normalized, componentState);
19
+ const graph = await discoverRouteGraph(normalized, options, componentState);
20
+ const routes = discoverRoutes(normalized, graph, options);
21
+ const analyzedRoutes = routes.map((route, index) => analyzeRoute(route, normalized, componentState, index));
22
+ const selectedRoute = selectRoute(analyzedRoutes);
23
+ const simulation = selectedRoute
24
+ ? simulateRoute(selectedRoute, normalized)
25
+ : noRouteSimulation(normalized, readinessPrediction);
26
+ const execution = buildVirtualExecution(normalized, selectedRoute, simulation);
27
+ const predictions = predictFailures(normalized, componentState, graph, analyzedRoutes, selectedRoute, simulation, readinessPrediction);
28
+ const visualization = buildPaymentVisualization(selectedRoute, simulation, execution, predictions);
29
+ const diagnostics = diagnoseVirtualPayment(normalized, componentState, graph, routes, selectedRoute, simulation);
30
+
31
+ return {
32
+ request: normalized,
33
+ componentState,
34
+ graph,
35
+ routes: analyzedRoutes,
36
+ selectedRoute,
37
+ simulation,
38
+ execution,
39
+ predictions,
40
+ visualization,
41
+ diagnostics,
42
+ };
43
+ }
44
+
45
+ export async function discoverPaymentRoutes(request, options = {}) {
46
+ const normalized = normalizePaymentRequest(request);
47
+ const componentState = await resolveComponentState(options);
48
+ const graph = await discoverRouteGraph(normalized, options, componentState);
49
+ return discoverRoutes(normalized, graph, options);
50
+ }
51
+
52
+ export function analyzePaymentRoute(route, request, componentState = {}) {
53
+ return analyzeRoute(route, normalizePaymentRequest(request), componentState, 0);
54
+ }
55
+
56
+ export function simulatePaymentRoute(route, request) {
57
+ return simulateRoute(route, normalizePaymentRequest(request));
58
+ }
59
+
60
+ export function simulatePaymentFees(route, request) {
61
+ return simulateRoute(route, normalizePaymentRequest(request)).feeBreakdown;
62
+ }
63
+
64
+ export function buildPaymentExecution(route, request) {
65
+ const normalized = normalizePaymentRequest(request);
66
+ const analyzedRoute = route ? (route.analysis ? route : analyzeRoute(route, normalized, {}, 0)) : undefined;
67
+ const simulation = analyzedRoute ? simulateRoute(analyzedRoute, normalized) : noRouteSimulation(normalized);
68
+ return buildVirtualExecution(normalized, analyzedRoute, simulation);
69
+ }
70
+
71
+ export function buildPaymentVisualization(route, simulation, execution, predictions = []) {
72
+ return {
73
+ route: route ?? null,
74
+ liquidityChanges: simulation.liquidityChanges,
75
+ feeBreakdown: simulation.feeBreakdown,
76
+ executionSteps: execution.steps,
77
+ predictions,
78
+ };
79
+ }
80
+
81
+ export function predictPaymentFailures(route, request, componentState = {}) {
82
+ const normalized = normalizePaymentRequest(request);
83
+ const readinessPrediction = predictComponentReadinessFailure(normalized, componentState);
84
+ const analyzedRoute = route ? (route.analysis ? route : analyzeRoute(route, normalized, componentState, 0)) : undefined;
85
+ const simulation = analyzedRoute
86
+ ? simulateRoute(analyzedRoute, normalized)
87
+ : noRouteSimulation(normalized, readinessPrediction);
88
+ return predictFailures(normalized, componentState, { channels: [] }, analyzedRoute ? [analyzedRoute] : [], analyzedRoute, simulation, readinessPrediction);
89
+ }
90
+
91
+ export function normalizePaymentRequest(request = {}) {
92
+ const sourceNode = stringField(request, "sourceNode", "source node");
93
+ const destinationNode = stringField(request, "destinationNode", "destination node");
94
+ const amount = amountField(request.amount);
95
+ const assetType = request.assetType ? String(request.assetType) : "CKB";
96
+ if (sourceNode === destinationNode) {
97
+ throw new FiberRouteVirtualizerError("sourceNode and destinationNode must be different.", "INVALID_PAYMENT_REQUEST");
98
+ }
99
+ return { sourceNode, destinationNode, amount, assetType };
100
+ }
101
+
102
+ export async function discoverRouteGraph(request, options = {}, componentState = {}) {
103
+ if (options.graph) return normalizeGraph(options.graph);
104
+ const localGraph = graphFromComponentState(componentState, request);
105
+ if (options.rpcUrl) {
106
+ const rpcGraph = await graphFromRpc(options.rpcUrl, options);
107
+ return mergeGraphs(rpcGraph, localGraph);
108
+ }
109
+ return localGraph;
110
+ }
111
+
112
+ export function discoverRoutes(request, graph, options = {}) {
113
+ const normalizedGraph = normalizeGraph(graph);
114
+ const nodeById = new Map(normalizedGraph.nodes.map((node) => [node.nodeId, node]));
115
+ const maxHops = options.maxHops ?? 6;
116
+ const maxRoutes = options.maxRoutes ?? 10;
117
+ const queue = [{ nodeId: request.sourceNode, hops: [], visited: new Set([request.sourceNode]) }];
118
+ const routes = [];
119
+
120
+ while (queue.length > 0 && routes.length < maxRoutes) {
121
+ const current = queue.shift();
122
+ if (current.hops.length >= maxHops) continue;
123
+ const outgoing = normalizedGraph.channels.filter((channel) =>
124
+ channel.sourceNode === current.nodeId &&
125
+ !current.visited.has(channel.destinationNode) &&
126
+ assetMatches(channel.assetType, request.assetType)
127
+ );
128
+ for (const channel of outgoing) {
129
+ const fromNode = nodeById.get(channel.sourceNode);
130
+ const toNode = nodeById.get(channel.destinationNode);
131
+ const hop = {
132
+ nodeId: channel.destinationNode,
133
+ channelId: channel.channelId,
134
+ fromNode: channel.sourceNode,
135
+ toNode: channel.destinationNode,
136
+ assetType: channel.assetType,
137
+ capacity: channel.capacity,
138
+ localBalance: channel.localBalance,
139
+ remoteBalance: channel.remoteBalance,
140
+ outboundLiquidity: channel.outboundLiquidity,
141
+ feeRate: channel.feeRate,
142
+ baseFee: channel.baseFee,
143
+ enabled: channel.enabled,
144
+ status: channel.status,
145
+ fromNodeOnline: knownNodeOnline(fromNode),
146
+ toNodeOnline: knownNodeOnline(toNode),
147
+ };
148
+ const hops = [...current.hops, hop];
149
+ if (channel.destinationNode === request.destinationNode) {
150
+ routes.push({ hops });
151
+ } else {
152
+ queue.push({
153
+ nodeId: channel.destinationNode,
154
+ hops,
155
+ visited: new Set([...current.visited, channel.destinationNode]),
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ return routes;
162
+ }
163
+
164
+ function analyzeRoute(route, request, componentState, index) {
165
+ const context = routeContext(componentState);
166
+ const hops = [];
167
+ let amountToForward = request.amount;
168
+
169
+ for (let hopIndex = route.hops.length - 1; hopIndex >= 0; hopIndex -= 1) {
170
+ const hop = route.hops[hopIndex];
171
+ const fee = estimateHopFee(hop, amountToForward);
172
+ const incomingAmount = amountToForward + fee.hopFee;
173
+ const localBefore = hop.localBalance ?? hop.outboundLiquidity;
174
+ const remoteBefore = deriveRemoteBalance(hop, localBefore);
175
+ const channelStatus = hop.status ?? context.channels.get(hop.channelId)?.status;
176
+ const channelOpen = channelStatus ? channelStatus === "open" : hop.enabled !== false;
177
+ const fromNodeOnline = resolveNodeOnline(hop.fromNode, hop.fromNodeOnline, context);
178
+ const toNodeOnline = resolveNodeOnline(hop.toNode, hop.toNodeOnline, context);
179
+ const liquidityKnown = localBefore !== undefined;
180
+ const hasLiquidity = liquidityKnown && localBefore >= incomingAmount;
181
+
182
+ hops.unshift({
183
+ ...hop,
184
+ step: hopIndex + 1,
185
+ estimatedFee: fee.hopFee,
186
+ outgoingAmount: amountToForward,
187
+ incomingAmount,
188
+ requiredOutbound: incomingAmount,
189
+ baseFee: hop.baseFee ?? 0n,
190
+ proportionalFee: fee.proportionalFee,
191
+ localBefore,
192
+ localAfter: localBefore === undefined ? undefined : localBefore - incomingAmount,
193
+ remoteBefore,
194
+ remoteAfter: remoteBefore === undefined ? undefined : remoteBefore + incomingAmount,
195
+ channelOpen,
196
+ channelStatus,
197
+ fromNodeOnline,
198
+ toNodeOnline,
199
+ liquidityKnown,
200
+ hasLiquidity,
201
+ liquidityAfter: localBefore === undefined ? undefined : localBefore - incomingAmount,
202
+ });
203
+ amountToForward = incomingAmount;
204
+ }
205
+
206
+ const totalFee = amountToForward - request.amount;
207
+ const issues = analyzeHopIssues(hops);
208
+ const routeAnalysis = {
209
+ routeValid: hops.length > 0 && issues.length === 0,
210
+ issues,
211
+ };
212
+
213
+ return {
214
+ id: `route-${index + 1}`,
215
+ hops,
216
+ hopCount: hops.length,
217
+ totalFee,
218
+ amountDelivered: request.amount,
219
+ amountDebited: amountToForward,
220
+ assetType: request.assetType,
221
+ analysis: routeAnalysis,
222
+ routeValid: routeAnalysis.routeValid,
223
+ issues,
224
+ viable: routeAnalysis.routeValid,
225
+ sourceReadiness: readinessSummary(componentState),
226
+ };
227
+ }
228
+
229
+ function simulateRoute(route, request) {
230
+ const analyzedRoute = route.analysis ? route : analyzeRoute(route, request, {}, 0);
231
+ const steps = analyzedRoute.hops.map((hop) => {
232
+ const hopIssues = analyzedRoute.analysis.issues.filter((issue) => issue.hop === hop.step);
233
+ const reason = hopIssues[0]?.code;
234
+ return {
235
+ step: hop.step,
236
+ channelId: hop.channelId,
237
+ fromNode: hop.fromNode,
238
+ toNode: hop.toNode,
239
+ outgoingAmount: hop.outgoingAmount,
240
+ incomingAmount: hop.incomingAmount,
241
+ hopFee: hop.estimatedFee,
242
+ baseFee: hop.baseFee ?? 0n,
243
+ proportionalFee: hop.proportionalFee ?? 0n,
244
+ beforeLocal: hop.localBefore,
245
+ afterLocal: hop.localAfter,
246
+ beforeRemote: hop.remoteBefore,
247
+ afterRemote: hop.remoteAfter,
248
+ outboundBefore: hop.localBefore,
249
+ outboundAfter: hop.localAfter,
250
+ status: hopIssues.length === 0 ? "pass" : "blocked",
251
+ reason,
252
+ issues: hopIssues,
253
+ };
254
+ });
255
+
256
+ const blocked = steps.find((step) => step.status === "blocked");
257
+ return {
258
+ result: blocked ? "failure" : "success",
259
+ amount: request.amount,
260
+ totalFee: analyzedRoute.totalFee,
261
+ totalDebit: analyzedRoute.amountDebited,
262
+ routeAnalysis: analyzedRoute.analysis,
263
+ liquidityChanges: steps.map(liquidityChangeFromStep),
264
+ feeBreakdown: buildFeeBreakdown(steps),
265
+ steps,
266
+ failure: blocked ? { step: blocked.step, code: blocked.reason, channelId: blocked.channelId, issues: blocked.issues } : undefined,
267
+ };
268
+ }
269
+
270
+ function noRouteSimulation(request, readinessPrediction = undefined) {
271
+ return {
272
+ result: "failure",
273
+ amount: request.amount,
274
+ totalFee: 0n,
275
+ totalDebit: request.amount,
276
+ liquidityChanges: [],
277
+ feeBreakdown: {
278
+ hopFees: [],
279
+ totalFee: 0n,
280
+ },
281
+ steps: [],
282
+ failure: {
283
+ step: 0,
284
+ code: readinessPrediction?.code ?? "NO_ROUTE",
285
+ },
286
+ };
287
+ }
288
+
289
+ function buildVirtualExecution(request, route, simulation) {
290
+ const steps = [];
291
+ steps.push(executionStep(
292
+ steps.length + 1,
293
+ "Route Found",
294
+ route ? "success" : "failed",
295
+ route ? `${route.hopCount} hop route selected.` : "No route could be selected."
296
+ ));
297
+
298
+ if (route) {
299
+ for (const hop of simulation.steps) {
300
+ const hopStatus = hop.status === "pass" ? "success" : "failed";
301
+ steps.push(executionStep(
302
+ steps.length + 1,
303
+ `Hop ${hop.step} Validated`,
304
+ hopStatus,
305
+ hop.reason
306
+ ? `${short(hop.fromNode)} -> ${short(hop.toNode)} failed with ${hop.reason}.`
307
+ : `${short(hop.fromNode)} -> ${short(hop.toNode)} is online, open, and liquid enough.`
308
+ ));
309
+ steps.push(executionStep(
310
+ steps.length + 1,
311
+ "Liquidity Reserved",
312
+ hopStatus,
313
+ hopStatus === "success"
314
+ ? `${hop.incomingAmount.toString()} ${request.assetType} reserved on ${short(hop.channelId)}.`
315
+ : `Cannot reserve ${hop.incomingAmount.toString()} ${request.assetType} on ${short(hop.channelId)}.`
316
+ ));
317
+ steps.push(executionStep(
318
+ steps.length + 1,
319
+ hop.toNode === request.destinationNode ? "Forwarded To Receiver" : `Forwarded To Hop ${hop.step + 1}`,
320
+ hopStatus,
321
+ hopStatus === "success"
322
+ ? `${hop.outgoingAmount.toString()} ${request.assetType} virtually forwarded to ${short(hop.toNode)}.`
323
+ : `Forwarding stopped at ${short(hop.toNode)}.`
324
+ ));
325
+ if (hopStatus === "failed") break;
326
+ }
327
+ }
328
+
329
+ steps.push(executionStep(
330
+ steps.length + 1,
331
+ simulation.result === "success" ? "Settled" : "Not Settled",
332
+ simulation.result === "success" ? "success" : "failed",
333
+ simulation.result === "success"
334
+ ? `Payment can deliver ${request.amount.toString()} ${request.assetType} with estimated fee ${simulation.feeBreakdown.totalFee.toString()}.`
335
+ : `Payment simulation failed: ${simulation.failure?.code ?? "UNKNOWN"}.`
336
+ ));
337
+
338
+ return {
339
+ readOnly: true,
340
+ broadcast: false,
341
+ mutatesChannels: false,
342
+ steps,
343
+ };
344
+ }
345
+
346
+ function buildVisualization(request, route, simulation, execution) {
347
+ return {
348
+ title: `Send ${request.amount.toString()} ${request.assetType}`,
349
+ route: route
350
+ ? [request.sourceNode, ...route.hops.map((hop) => hop.toNode)]
351
+ : [request.sourceNode, request.destinationNode],
352
+ routeText: route
353
+ ? [request.sourceNode, ...route.hops.map((hop) => hop.toNode)].map(short).join(" -> ")
354
+ : `${short(request.sourceNode)} -/-> ${short(request.destinationNode)}`,
355
+ lanes: [
356
+ { id: "route", label: "Route Selection" },
357
+ { id: "liquidity", label: "Liquidity Consumption" },
358
+ { id: "fees", label: "Fee Accumulation" },
359
+ { id: "result", label: "Final Result" },
360
+ ],
361
+ nodes: execution.steps.map((step) => ({
362
+ id: `step-${step.step}`,
363
+ label: step.title,
364
+ lane: laneForExecutionStep(step),
365
+ status: step.status,
366
+ })),
367
+ edges: simulation.steps.map((step) => ({
368
+ from: step.fromNode,
369
+ to: step.toNode,
370
+ channelId: step.channelId,
371
+ label: `${step.incomingAmount.toString()} ${request.assetType}`,
372
+ })),
373
+ summary: {
374
+ result: simulation.result,
375
+ amount: request.amount,
376
+ totalFee: simulation.feeBreakdown.totalFee,
377
+ totalDebit: simulation.totalDebit,
378
+ failure: simulation.failure,
379
+ },
380
+ };
381
+ }
382
+
383
+ function predictFailures(request, componentState, graph, routes, selectedRoute, simulation, readinessPrediction = undefined) {
384
+ const predictions = [];
385
+ if (readinessPrediction) predictions.push(readinessPrediction);
386
+
387
+ const sourceDiagnostics = Array.isArray(componentState.diagnostics) ? componentState.diagnostics : [];
388
+ for (const diagnostic of sourceDiagnostics) {
389
+ if (diagnostic.severity === "critical") {
390
+ predictions.push(failurePrediction(
391
+ diagnostic.code,
392
+ 0.95,
393
+ diagnostic.description ?? diagnostic.title ?? "Component 1 reports a critical source node problem.",
394
+ diagnostic.recommendation ?? "Resolve the source node diagnostic before simulating payment routes."
395
+ ));
396
+ }
397
+ }
398
+
399
+ if ((graph.channels ?? []).length === 0) {
400
+ predictions.push(failurePrediction(
401
+ "NO_GRAPH_CHANNELS",
402
+ 0.9,
403
+ "No Fiber channel edges are available for route discovery.",
404
+ "Query Fiber graph data, connect peers, or open channels before attempting payment."
405
+ ));
406
+ }
407
+
408
+ if (routes.length === 0) {
409
+ predictions.push(failurePrediction(
410
+ "NO_ROUTE",
411
+ 0.88,
412
+ "No route connects the source node to the destination node for this asset.",
413
+ "Use an alternate receiver route, connect to more peers, or wait for graph gossip."
414
+ ));
415
+ }
416
+
417
+ if (selectedRoute) {
418
+ for (const issue of selectedRoute.analysis.issues) {
419
+ predictions.push(predictionFromRouteIssue(issue));
420
+ }
421
+ }
422
+
423
+ if (simulation.result === "success" && predictions.length === 0) {
424
+ predictions.push(failurePrediction(
425
+ "LIKELY_SUCCESS",
426
+ 0.82,
427
+ "The selected route is open and has enough known liquidity for the requested amount plus estimated fees.",
428
+ "Proceed with this route or compare alternate routes for lower fees."
429
+ ));
430
+ }
431
+
432
+ return dedupePredictions(predictions);
433
+ }
434
+
435
+ function predictComponentReadinessFailure(request, componentState = {}) {
436
+ const maxSendable = amountMaybe(componentState.liquidity?.totalOutbound);
437
+ if (maxSendable !== undefined && request.amount > maxSendable) {
438
+ return failurePrediction(
439
+ "INSUFFICIENT_OUTBOUND_LIQUIDITY",
440
+ 0.98,
441
+ `Requested amount ${request.amount.toString()} exceeds total outbound liquidity ${maxSendable.toString()}.`,
442
+ "Add outbound liquidity, rebalance channels, or try a smaller payment amount."
443
+ );
444
+ }
445
+ if (componentState.node?.online === false) {
446
+ return failurePrediction(
447
+ "NODE_OFFLINE",
448
+ 0.99,
449
+ "The source Fiber node is offline according to Component 1.",
450
+ "Verify FNN is running and reachable before attempting payment."
451
+ );
452
+ }
453
+ if ((componentState.peers ?? []).length === 0) {
454
+ return failurePrediction(
455
+ "NO_PEERS",
456
+ 0.95,
457
+ "The source node has no known Fiber peers.",
458
+ "Connect to a Fiber peer before attempting payment."
459
+ );
460
+ }
461
+ if ((componentState.channels ?? []).length === 0) {
462
+ return failurePrediction(
463
+ "NO_CHANNELS",
464
+ 0.95,
465
+ "The source node has no Fiber channels.",
466
+ "Open or discover a usable Fiber channel before attempting payment."
467
+ );
468
+ }
469
+ return undefined;
470
+ }
471
+
472
+ function predictionFromRouteIssue(issue) {
473
+ if (issue.code === "INSUFFICIENT_LIQUIDITY") {
474
+ return failurePrediction(
475
+ "INSUFFICIENT_LIQUIDITY",
476
+ 0.94,
477
+ `Hop ${issue.hop} lacks liquidity: required ${stringAmount(issue.required)}, available ${stringAmount(issue.available)}.`,
478
+ "Rebalance the channel, choose an alternate route, or reduce the payment amount."
479
+ );
480
+ }
481
+ if (issue.code === "CHANNEL_NOT_OPEN") {
482
+ return failurePrediction(
483
+ "CHANNEL_NOT_OPEN",
484
+ 0.93,
485
+ `Hop ${issue.hop} uses a channel that is not open.`,
486
+ "Wait for the channel to open or use an alternate route."
487
+ );
488
+ }
489
+ if (issue.code === "NODE_OFFLINE") {
490
+ return failurePrediction(
491
+ "NODE_OFFLINE",
492
+ 0.92,
493
+ `Hop ${issue.hop} includes a node known to be offline.`,
494
+ "Reconnect the peer or route around this node."
495
+ );
496
+ }
497
+ if (issue.code === "UNKNOWN_LIQUIDITY") {
498
+ return failurePrediction(
499
+ "UNKNOWN_LIQUIDITY",
500
+ 0.62,
501
+ `Hop ${issue.hop} has unknown outbound liquidity, so payment success cannot be confirmed.`,
502
+ "Refresh graph data or use a route with known liquidity."
503
+ );
504
+ }
505
+ return failurePrediction(
506
+ issue.code,
507
+ 0.7,
508
+ issue.message ?? `Hop ${issue.hop} has a route issue.`,
509
+ "Inspect this hop and try an alternate route if needed."
510
+ );
511
+ }
512
+
513
+ function failurePrediction(code, confidence, explanation, recommendation) {
514
+ return { code, confidence, explanation, recommendation };
515
+ }
516
+
517
+ function dedupePredictions(predictions) {
518
+ const seen = new Set();
519
+ const deduped = [];
520
+ for (const prediction of predictions) {
521
+ const key = `${prediction.code}:${prediction.explanation}`;
522
+ if (seen.has(key)) continue;
523
+ seen.add(key);
524
+ deduped.push(prediction);
525
+ }
526
+ return deduped.sort((left, right) => right.confidence - left.confidence);
527
+ }
528
+
529
+ function diagnoseVirtualPayment(request, componentState, graph, routes, selectedRoute, simulation) {
530
+ const diagnostics = [];
531
+ const componentDiagnostics = Array.isArray(componentState.diagnostics) ? componentState.diagnostics : [];
532
+ for (const diagnostic of componentDiagnostics) {
533
+ if (diagnostic.severity === "critical") {
534
+ diagnostics.push({
535
+ code: `SOURCE_${diagnostic.code}`,
536
+ severity: diagnostic.severity,
537
+ title: diagnostic.title,
538
+ description: diagnostic.description,
539
+ recommendation: diagnostic.recommendation,
540
+ });
541
+ }
542
+ }
543
+ if (graph.channels.length === 0) {
544
+ diagnostics.push(diagnostic("NO_GRAPH_CHANNELS", "critical", "No route graph channels", "Route discovery had no channel edges to evaluate.", "Query Fiber graph data or open/connect channels."));
545
+ }
546
+ if (routes.length === 0) {
547
+ diagnostics.push(diagnostic("NO_ROUTE", "critical", "No payment route found", "No path connects the source node to the destination node for the requested asset.", "Connect to more peers or wait for graph gossip."));
548
+ }
549
+ if (selectedRoute && !selectedRoute.viable) {
550
+ diagnostics.push(diagnostic("ROUTE_NOT_VIABLE", "warning", "Route exists but is not viable", "At least one hop is disabled or lacks enough outbound liquidity.", "Try a smaller amount or another route."));
551
+ }
552
+ if (simulation.failure?.code === "INSUFFICIENT_LIQUIDITY") {
553
+ diagnostics.push(diagnostic("INSUFFICIENT_ROUTE_LIQUIDITY", "critical", "Insufficient route liquidity", "A route hop cannot carry the requested amount plus fees.", "Try a lower amount or rebalance/open channels."));
554
+ }
555
+ if (simulation.failure?.code === "CHANNEL_NOT_OPEN") {
556
+ diagnostics.push(diagnostic("ROUTE_CHANNEL_NOT_OPEN", "critical", "Route channel is not open", "A route hop uses a channel that is not currently open.", "Wait for the channel to open or select another route."));
557
+ }
558
+ if (simulation.failure?.code === "NODE_OFFLINE") {
559
+ diagnostics.push(diagnostic("ROUTE_NODE_OFFLINE", "critical", "Route node is offline", "A route hop includes a node known to be offline.", "Reconnect the peer or select another route."));
560
+ }
561
+ if (diagnostics.length === 0) {
562
+ diagnostics.push(diagnostic("PAYMENT_VIRTUALIZED", "info", "Payment route appears viable", "The simulation found a route with enough known liquidity."));
563
+ }
564
+ return diagnostics;
565
+ }
566
+
567
+ async function resolveComponentState(options) {
568
+ if (options.componentState) return options.componentState;
569
+ const pulseUrl = options.pulseUrl;
570
+ const pulseModule = options.pulseModule;
571
+ if (pulseUrl) {
572
+ const response = await fetch(`${pulseUrl.replace(/\/$/, "")}/state`);
573
+ if (!response.ok) throw new FiberRouteVirtualizerError(`Pulse /state failed with HTTP ${response.status}`, "PULSE_STATE_FAILED");
574
+ return response.json();
575
+ }
576
+ if (pulseModule && options.rpcUrl) {
577
+ const imported = await import(resolveImportSpecifier(pulseModule));
578
+ return imported.collectFiberStateReport({ rpcUrl: options.rpcUrl, timeoutMs: options.timeoutMs ?? 8000 });
579
+ }
580
+ return {};
581
+ }
582
+
583
+ async function graphFromRpc(rpcUrl, options) {
584
+ const nodes = await safeRpc(rpcUrl, "graph_nodes", [{}], options);
585
+ const channels = await safeRpc(rpcUrl, "graph_channels", [{}], options);
586
+ return normalizeGraph({
587
+ nodes: extractList(nodes, "nodes").map((node) => ({
588
+ nodeId: node.pubkey ?? node.nodeId ?? node.node_id,
589
+ addresses: node.addresses ?? [],
590
+ version: node.version,
591
+ features: node.features ?? [],
592
+ online: node.online,
593
+ connected: node.connected,
594
+ })),
595
+ channels: extractList(channels, "channels").flatMap(graphChannelToEdges),
596
+ });
597
+ }
598
+
599
+ function graphFromComponentState(componentState = {}, request) {
600
+ const source = componentState.node?.nodeId ?? request.sourceNode;
601
+ const nodes = [
602
+ componentState.node ? {
603
+ nodeId: componentState.node.nodeId,
604
+ version: componentState.node.version,
605
+ online: componentState.node.online,
606
+ } : undefined,
607
+ ...(componentState.peers ?? []).map((peer) => ({
608
+ nodeId: peer.peerId,
609
+ address: peer.address,
610
+ connected: peer.connected,
611
+ online: peer.connected,
612
+ lastSeen: peer.lastSeen,
613
+ })),
614
+ ].filter(Boolean);
615
+ const channels = (componentState.channels ?? []).flatMap((channel) => {
616
+ const capacity = amountMaybe(channel.capacity);
617
+ const localBalance = amountMaybe(channel.localBalance);
618
+ const remoteBalance = amountMaybe(channel.remoteBalance);
619
+ const local = {
620
+ channelId: channel.channelId,
621
+ sourceNode: source,
622
+ destinationNode: channel.peerId,
623
+ assetType: channel.assetType ?? "CKB",
624
+ capacity,
625
+ localBalance,
626
+ remoteBalance,
627
+ outboundLiquidity: localBalance,
628
+ enabled: channel.status === "open",
629
+ status: channel.status,
630
+ };
631
+ const remote = {
632
+ channelId: channel.channelId,
633
+ sourceNode: channel.peerId,
634
+ destinationNode: source,
635
+ assetType: channel.assetType ?? "CKB",
636
+ capacity,
637
+ localBalance: remoteBalance,
638
+ remoteBalance: localBalance,
639
+ outboundLiquidity: remoteBalance,
640
+ enabled: channel.status === "open",
641
+ status: channel.status,
642
+ };
643
+ return [local, remote];
644
+ });
645
+ return normalizeGraph({ nodes, channels });
646
+ }
647
+
648
+ function graphChannelToEdges(channel) {
649
+ const node1 = channel.node1 ?? channel.node_a ?? channel.source ?? channel.pubkey1;
650
+ const node2 = channel.node2 ?? channel.node_b ?? channel.target ?? channel.pubkey2;
651
+ if (!node1 || !node2) return [];
652
+ const channelId = channel.channel_id ?? channel.channelId ?? channel.channel_outpoint ?? "";
653
+ const assetType = channel.udt_type_script ? `UDT:${channel.udt_type_script.code_hash ?? "unknown"}` : "CKB";
654
+ const capacity = amountMaybe(channel.capacity);
655
+ const node1Liquidity = amountMaybe(channel.update_info_of_node1?.outbound_liquidity);
656
+ const node2Liquidity = amountMaybe(channel.update_info_of_node2?.outbound_liquidity);
657
+ const node1Balance = node1Liquidity ?? capacity;
658
+ const node2Balance = node2Liquidity ?? (capacity !== undefined && node1Balance !== undefined && capacity >= node1Balance ? capacity - node1Balance : undefined);
659
+ return [
660
+ {
661
+ channelId,
662
+ sourceNode: node1,
663
+ destinationNode: node2,
664
+ assetType,
665
+ capacity,
666
+ localBalance: node1Balance,
667
+ remoteBalance: node2Balance,
668
+ outboundLiquidity: node1Balance,
669
+ feeRate: amountMaybe(channel.update_info_of_node1?.fee_rate),
670
+ enabled: channel.update_info_of_node1?.enabled ?? true,
671
+ status: channel.status,
672
+ },
673
+ {
674
+ channelId,
675
+ sourceNode: node2,
676
+ destinationNode: node1,
677
+ assetType,
678
+ capacity,
679
+ localBalance: node2Balance,
680
+ remoteBalance: node1Balance,
681
+ outboundLiquidity: node2Balance,
682
+ feeRate: amountMaybe(channel.update_info_of_node2?.fee_rate),
683
+ enabled: channel.update_info_of_node2?.enabled ?? true,
684
+ status: channel.status,
685
+ },
686
+ ];
687
+ }
688
+
689
+ function normalizeGraph(graph = {}) {
690
+ return {
691
+ nodes: (graph.nodes ?? []).map((node) => ({
692
+ ...node,
693
+ nodeId: String(node.nodeId ?? node.node_id ?? node.pubkey ?? ""),
694
+ online: knownNodeOnline(node),
695
+ })).filter((node) => node.nodeId),
696
+ channels: (graph.channels ?? []).map((channel) => ({
697
+ channelId: String(channel.channelId ?? channel.channel_id ?? ""),
698
+ sourceNode: String(channel.sourceNode ?? channel.source_node ?? channel.fromNode ?? channel.from ?? ""),
699
+ destinationNode: String(channel.destinationNode ?? channel.destination_node ?? channel.toNode ?? channel.to ?? ""),
700
+ assetType: channel.assetType ?? channel.asset_type ?? "CKB",
701
+ capacity: amountMaybe(channel.capacity),
702
+ localBalance: amountMaybe(channel.localBalance ?? channel.local_balance),
703
+ remoteBalance: amountMaybe(channel.remoteBalance ?? channel.remote_balance),
704
+ outboundLiquidity: amountMaybe(channel.outboundLiquidity ?? channel.outbound_liquidity ?? channel.localBalance ?? channel.local_balance),
705
+ feeRate: amountMaybe(channel.feeRate ?? channel.fee_rate),
706
+ baseFee: amountMaybe(channel.baseFee ?? channel.base_fee),
707
+ status: channel.status,
708
+ enabled: channel.enabled ?? (channel.status ? channel.status === "open" : true),
709
+ })).filter((channel) => channel.sourceNode && channel.destinationNode),
710
+ };
711
+ }
712
+
713
+ function mergeGraphs(...graphs) {
714
+ const merged = { nodes: [], channels: [] };
715
+ const nodeById = new Map();
716
+ const channelByKey = new Map();
717
+ for (const graph of graphs.map(normalizeGraph)) {
718
+ for (const node of graph.nodes) {
719
+ const existing = nodeById.get(node.nodeId);
720
+ if (existing) {
721
+ mergeDefined(existing, node);
722
+ } else {
723
+ nodeById.set(node.nodeId, node);
724
+ merged.nodes.push(node);
725
+ }
726
+ }
727
+ for (const channel of graph.channels) {
728
+ const key = `${channel.channelId}:${channel.sourceNode}:${channel.destinationNode}:${channel.assetType}`;
729
+ const existing = channelByKey.get(key);
730
+ if (existing) {
731
+ mergeDefined(existing, channel);
732
+ } else {
733
+ channelByKey.set(key, channel);
734
+ merged.channels.push(channel);
735
+ }
736
+ }
737
+ }
738
+ return merged;
739
+ }
740
+
741
+ async function safeRpc(rpcUrl, method, params, options) {
742
+ try {
743
+ return await rpc(rpcUrl, method, params, options);
744
+ } catch {
745
+ return {};
746
+ }
747
+ }
748
+
749
+ async function rpc(rpcUrl, method, params = [], options = {}) {
750
+ const controller = new AbortController();
751
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000);
752
+ try {
753
+ const response = await fetch(rpcUrl, {
754
+ method: "POST",
755
+ headers: { "content-type": "application/json" },
756
+ body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }),
757
+ signal: controller.signal,
758
+ });
759
+ const payload = await response.json();
760
+ if (payload.error) throw new FiberRouteVirtualizerError(payload.error.message ?? "Fiber RPC error", "FIBER_RPC_ERROR", payload.error);
761
+ return payload.result;
762
+ } finally {
763
+ clearTimeout(timer);
764
+ }
765
+ }
766
+
767
+ function selectRoute(routes) {
768
+ return [...routes].sort((a, b) => {
769
+ if (a.viable !== b.viable) return a.viable ? -1 : 1;
770
+ if (a.hopCount !== b.hopCount) return a.hopCount - b.hopCount;
771
+ return compareBigInt(a.totalFee, b.totalFee);
772
+ })[0];
773
+ }
774
+
775
+ function estimateHopFee(hop, amount) {
776
+ const baseFee = hop.baseFee ?? 0n;
777
+ const rate = hop.feeRate ?? 0n;
778
+ const proportionalFee = (amount * rate) / 1_000_000n;
779
+ return {
780
+ hopFee: baseFee + proportionalFee,
781
+ baseFee,
782
+ proportionalFee,
783
+ downstreamReserve: 0n,
784
+ };
785
+ }
786
+
787
+ function buildFeeBreakdown(steps) {
788
+ const hopFees = steps.map((step) => ({
789
+ nodeId: step.toNode,
790
+ channelId: step.channelId,
791
+ baseFee: step.baseFee,
792
+ proportionalFee: step.proportionalFee,
793
+ fee: step.hopFee,
794
+ }));
795
+ return {
796
+ hopFees,
797
+ totalFee: hopFees.reduce((sum, hop) => sum + hop.fee, 0n),
798
+ };
799
+ }
800
+
801
+ function executionStep(step, title, status, details) {
802
+ return { step, title, status, details };
803
+ }
804
+
805
+ function laneForExecutionStep(step) {
806
+ if (step.title.includes("Liquidity")) return "liquidity";
807
+ if (step.title.includes("Settled")) return "result";
808
+ if (step.title.includes("Forwarded")) return "route";
809
+ return "route";
810
+ }
811
+
812
+ function analyzeHopIssues(hops) {
813
+ const issues = [];
814
+ for (const hop of hops) {
815
+ if (hop.fromNodeOnline === false) {
816
+ issues.push(routeIssue("NODE_OFFLINE", hop, "Source side of this hop is known to be offline."));
817
+ }
818
+ if (hop.toNodeOnline === false) {
819
+ issues.push(routeIssue("NODE_OFFLINE", hop, "Destination side of this hop is known to be offline."));
820
+ }
821
+ if (!hop.channelOpen) {
822
+ issues.push(routeIssue("CHANNEL_NOT_OPEN", hop, `Channel status is ${hop.channelStatus ?? "disabled"}.`));
823
+ }
824
+ if (!hop.liquidityKnown) {
825
+ issues.push(routeIssue("UNKNOWN_LIQUIDITY", hop, "Outbound liquidity is not available for this hop."));
826
+ } else if (!hop.hasLiquidity) {
827
+ issues.push(routeIssue("INSUFFICIENT_LIQUIDITY", hop, "Hop outbound liquidity cannot cover the amount plus fees."));
828
+ }
829
+ }
830
+ return issues;
831
+ }
832
+
833
+ function routeIssue(code, hop, message) {
834
+ return {
835
+ code,
836
+ hop: hop.step,
837
+ channelId: hop.channelId,
838
+ fromNode: hop.fromNode,
839
+ toNode: hop.toNode,
840
+ required: hop.requiredOutbound,
841
+ available: hop.localBefore,
842
+ message,
843
+ };
844
+ }
845
+
846
+ function liquidityChangeFromStep(step) {
847
+ return {
848
+ channelId: step.channelId,
849
+ beforeLocal: step.beforeLocal,
850
+ afterLocal: step.afterLocal,
851
+ beforeRemote: step.beforeRemote,
852
+ afterRemote: step.afterRemote,
853
+ fromNode: step.fromNode,
854
+ toNode: step.toNode,
855
+ amount: step.incomingAmount,
856
+ };
857
+ }
858
+
859
+ function routeContext(componentState = {}) {
860
+ const nodes = new Map();
861
+ const channels = new Map();
862
+ if (componentState.node?.nodeId) {
863
+ nodes.set(componentState.node.nodeId, {
864
+ online: componentState.node.online,
865
+ connected: componentState.node.online,
866
+ });
867
+ }
868
+ for (const peer of componentState.peers ?? []) {
869
+ nodes.set(peer.peerId, {
870
+ online: peer.connected,
871
+ connected: peer.connected,
872
+ lastSeen: peer.lastSeen,
873
+ });
874
+ }
875
+ for (const channel of componentState.channels ?? []) {
876
+ channels.set(channel.channelId, channel);
877
+ }
878
+ return { nodes, channels };
879
+ }
880
+
881
+ function knownNodeOnline(node) {
882
+ if (!node) return undefined;
883
+ if (typeof node.online === "boolean") return node.online;
884
+ if (typeof node.connected === "boolean") return node.connected;
885
+ return undefined;
886
+ }
887
+
888
+ function resolveNodeOnline(nodeId, hinted, context) {
889
+ if (typeof hinted === "boolean") return hinted;
890
+ const state = context.nodes.get(nodeId);
891
+ if (!state) return undefined;
892
+ return knownNodeOnline(state);
893
+ }
894
+
895
+ function deriveRemoteBalance(hop, localBefore) {
896
+ if (hop.remoteBalance !== undefined) return hop.remoteBalance;
897
+ if (hop.capacity !== undefined && localBefore !== undefined && hop.capacity >= localBefore) {
898
+ return hop.capacity - localBefore;
899
+ }
900
+ return undefined;
901
+ }
902
+
903
+ function mergeDefined(target, source) {
904
+ for (const [key, value] of Object.entries(source)) {
905
+ if (value !== undefined && value !== null && value !== "") {
906
+ target[key] = value;
907
+ }
908
+ }
909
+ return target;
910
+ }
911
+
912
+ function readinessSummary(componentState) {
913
+ if (!componentState.liquidity) return undefined;
914
+ return {
915
+ totalOutbound: amountMaybe(componentState.liquidity.totalOutbound),
916
+ totalInbound: amountMaybe(componentState.liquidity.totalInbound),
917
+ channelCount: componentState.liquidity.channelCount,
918
+ };
919
+ }
920
+
921
+ function diagnostic(code, severity, title, description, recommendation) {
922
+ return { code, severity, title, description, recommendation };
923
+ }
924
+
925
+ function extractList(value, key) {
926
+ if (Array.isArray(value)) return value;
927
+ if (Array.isArray(value?.[key])) return value[key];
928
+ return [];
929
+ }
930
+
931
+ function assetMatches(left, right) {
932
+ return String(left ?? "CKB").toLowerCase() === String(right ?? "CKB").toLowerCase();
933
+ }
934
+
935
+ function amountField(value) {
936
+ const amount = amountMaybe(value);
937
+ if (amount === undefined || amount <= 0n) {
938
+ throw new FiberRouteVirtualizerError("amount must be a positive integer.", "INVALID_PAYMENT_REQUEST");
939
+ }
940
+ return amount;
941
+ }
942
+
943
+ function amountMaybe(value) {
944
+ if (value === undefined || value === null || value === "") return undefined;
945
+ if (typeof value === "bigint") return value;
946
+ if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value));
947
+ if (typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value);
948
+ if (typeof value === "string" && /^[0-9]+$/.test(value)) return BigInt(value);
949
+ return undefined;
950
+ }
951
+
952
+ function stringAmount(value) {
953
+ return value === undefined ? "unknown" : value.toString();
954
+ }
955
+
956
+ function stringField(source, key, label) {
957
+ const value = source[key];
958
+ if (!value || typeof value !== "string") {
959
+ throw new FiberRouteVirtualizerError(`${label} is required.`, "INVALID_PAYMENT_REQUEST");
960
+ }
961
+ return value;
962
+ }
963
+
964
+ function compareBigInt(left, right) {
965
+ return left === right ? 0 : left < right ? -1 : 1;
966
+ }
967
+
968
+ function short(value) {
969
+ const text = String(value ?? "");
970
+ return text.length <= 14 ? text : `${text.slice(0, 8)}...${text.slice(-6)}`;
971
+ }
972
+
973
+ function resolveImportSpecifier(specifier) {
974
+ if (/^(file|data|node|https?):/.test(specifier)) return specifier;
975
+ if (specifier.startsWith(".") || specifier.includes("\\") || specifier.includes("/")) {
976
+ return pathToFileURL(path.resolve(process.cwd(), specifier)).href;
977
+ }
978
+ return specifier;
979
+ }
980
+
981
+ function stringify(value) {
982
+ return JSON.stringify(value, (_, nested) => typeof nested === "bigint" ? nested.toString() : nested, 2);
983
+ }
984
+
985
+ function routeText(result) {
986
+ const route = result.visualization?.route;
987
+ if (route?.hops?.length) {
988
+ const nodes = [result.request.sourceNode, ...route.hops.map((hop) => hop.toNode)];
989
+ return nodes.map(short).join(" -> ");
990
+ }
991
+ return `${short(result.request.sourceNode)} -/-> ${short(result.request.destinationNode)}`;
992
+ }
993
+
994
+ function parseCliArgs(argv) {
995
+ const options = {};
996
+ const request = {};
997
+ let command = argv[0]?.startsWith("--") ? "virtualize" : (argv.shift() ?? "help");
998
+ for (let index = 0; index < argv.length; index += 1) {
999
+ const raw = argv[index];
1000
+ const [flag, inline] = raw.split("=", 2);
1001
+ const value = () => inline ?? argv[++index];
1002
+ if (flag === "--source") request.sourceNode = value();
1003
+ else if (flag === "--destination") request.destinationNode = value();
1004
+ else if (flag === "--amount") request.amount = value();
1005
+ else if (flag === "--asset") request.assetType = value();
1006
+ else if (flag === "--rpc") options.rpcUrl = value();
1007
+ else if (flag === "--pulse-url") options.pulseUrl = value();
1008
+ else if (flag === "--pulse-module") options.pulseModule = value();
1009
+ else if (flag === "--max-hops") options.maxHops = Number(value());
1010
+ else if (flag === "--json") options.json = true;
1011
+ else if (flag === "--help" || flag === "-h") command = "help";
1012
+ else throw new FiberRouteVirtualizerError(`Unknown option ${flag}`, "CLI_USAGE_ERROR");
1013
+ }
1014
+ return { command, request, options };
1015
+ }
1016
+
1017
+ function help() {
1018
+ return [
1019
+ "Fiber Payment Route Virtualizer",
1020
+ "",
1021
+ "Usage:",
1022
+ " node route.mjs virtualize --source <node> --destination <node> --amount <amount> [--rpc <url>] --json",
1023
+ " route virtualize --source <node> --destination <node> --amount <amount> [--rpc <url>] --json",
1024
+ "",
1025
+ "Options:",
1026
+ " --source <node> Source Fiber node pubkey",
1027
+ " --destination <node> Destination Fiber node pubkey",
1028
+ " --amount <amount> Amount as decimal or 0x integer",
1029
+ " --asset <asset> Asset type. Default: CKB",
1030
+ " --rpc <url> Fiber RPC URL for graph discovery",
1031
+ " --pulse-url <url> Pulse API URL for Component 1 state",
1032
+ " --pulse-module <path> Import Pulse module and call collectFiberStateReport",
1033
+ " --max-hops <n> Max route depth. Default: 6",
1034
+ " --json Print full JSON",
1035
+ ].join("\n");
1036
+ }
1037
+
1038
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1039
+ const { command, request, options } = parseCliArgs(process.argv.slice(2));
1040
+ if (command === "help") {
1041
+ console.log(help());
1042
+ } else if (command === "virtualize") {
1043
+ virtualizePayment(request, options)
1044
+ .then((result) => console.log(options.json ? stringify(result) : routeText(result)))
1045
+ .catch((error) => {
1046
+ console.error(error instanceof Error ? error.message : String(error));
1047
+ process.exitCode = 1;
1048
+ });
1049
+ } else {
1050
+ console.error(`Unknown command ${command}`);
1051
+ process.exitCode = 1;
1052
+ }
1053
+ }