@fibscope/agent 0.1.2 → 0.1.4

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 CHANGED
@@ -5,3 +5,5 @@ FIBSCOPE_AGENT_CHAIN=testnet
5
5
  FIBSCOPE_AGENT_FIBER_RPC_URL=http://127.0.0.1:8227
6
6
  FIBSCOPE_AGENT_PUSH_INTERVAL_MS=15000
7
7
  FIBSCOPE_AGENT_PUBLIC_PROFILE=false
8
+ # Optional override; by default the agent discovers its own public IPv4.
9
+ # FIBSCOPE_AGENT_PUBLIC_IP=54.179.226.154
package/README.md CHANGED
@@ -48,7 +48,7 @@ The agent also supports `.fiber/agent.json`:
48
48
  }
49
49
  ```
50
50
 
51
- The hosted gateway returns the public IPv4 it observes for the authenticated agent request. The agent combines that IP with the local FNN P2P port and reports the resulting public multiaddr. Loopback, wildcard, private, and documentation addresses are never published. Set `peerAddress` only when intentionally overriding automatic discovery with another publicly dialable multiaddr.
51
+ The agent discovers its own public IPv4 through `https://api.ipify.org`, combines it with the local FNN P2P port, and includes both the IP and resulting public multiaddr in registration and report requests. Loopback, wildcard, private, and documentation addresses are never published. Set `FIBSCOPE_AGENT_PUBLIC_IP` only when intentionally overriding automatic discovery, or `FIBSCOPE_AGENT_PUBLIC_IP_URL` to use another compatible JSON or plain-text discovery endpoint.
52
52
 
53
53
  ## Commands
54
54
 
@@ -74,9 +74,11 @@ The agent pushes to `POST /agent/v1/report` with:
74
74
 
75
75
  ```json
76
76
  {
77
- "agentVersion": "0.1.2",
77
+ "agentVersion": "0.1.4",
78
78
  "chain": "testnet",
79
79
  "collectedAt": 1710000000000,
80
+ "publicIp": "54.179.226.154",
81
+ "peerAddress": "/ip4/54.179.226.154/tcp/8228",
80
82
  "node": {},
81
83
  "peers": [],
82
84
  "channels": [],
@@ -92,6 +94,12 @@ Fibscope authenticates this with `x-agent-token`. This is an agent key for local
92
94
 
93
95
  Before the first push, the agent registers the local Fiber node with `POST /agent/v1/register`. That saves the node owner, Fiber node id, display name, chain, public profile setting, and the latest local node details in the hosted database. If the agent key is already attached to a different registered Fiber node, set `overwriteNodeRegistration` to `true` only when you intentionally want this agent to replace that old node binding.
94
96
 
97
+ ## API Node Payment Lifecycle
98
+
99
+ For an `API_NODE_PAYMENT`, the agent confirms the sender deposit and checks for a viable existing Fiber route. When the quote permits provisioning and no route exists, it connects the receiving peer, opens a direct funded channel, waits for readiness, and sends the payment. A close-after-test policy applies only to the channel opened by that payment; existing channels are never closed automatically.
100
+
101
+ The command result includes provider and receiver balance views before payment, after payment, and at close. The receiver view reverses the provider-local and provider-remote balances so the receiving channel's credited local balance and remaining inbound capacity are explicit.
102
+
95
103
  ## Resilience
96
104
 
97
105
  Collection and transmission are independent loops:
@@ -117,7 +125,7 @@ The Agent Setup page generates this command from a masked password field. The `f
117
125
  ## NPM Usage
118
126
 
119
127
  ```bash
120
- npm install -g @fibscope/agent@0.1.2 pm2 && agent init && pm2 start "$(command -v agent)" --name fibscope-agent --interpreter node -- start && pm2 save
128
+ npm install -g @fibscope/agent pm2 && agent init && pm2 start "$(command -v agent)" --name fibscope-agent --interpreter none -- start && pm2 save
121
129
  ```
122
130
 
123
131
  Or run without a global install:
package/index.mjs CHANGED
@@ -31,15 +31,17 @@ import { createTelemetryCollector } from "./lib/observe/collector.mjs";
31
31
  import { recommend } from "./lib/bridge/recommendation.mjs";
32
32
  import { virtualizePayment } from "./lib/route/route.mjs";
33
33
  import { chooseFnnInstance, describeFnnInstance, discoverFnnInstances, rpcResponds } from "./lib/fnn-discovery.mjs";
34
- import { buildObservedPeerAddress, peerTcpPort, publishablePeerAddresses } from "./lib/public-peer-address.mjs";
34
+ import { buildObservedPeerAddress, discoverPublicIpv4, publishablePeerAddresses } from "./lib/public-peer-address.mjs";
35
35
 
36
- const AGENT_VERSION = "0.1.2";
36
+ const AGENT_VERSION = "0.1.4";
37
37
  const DEFAULT_CONFIG_PATH = ".fiber/agent.json";
38
38
  const DEFAULT_STATUS_PATH = ".fiber/agent-status.json";
39
39
  const DEFAULT_PUSH_INTERVAL_MS = 15_000;
40
40
  const MAX_BACKOFF_MS = 300_000;
41
41
  const DEFAULT_PULSE_MODULE = new URL("./lib/pulse/index.mjs", import.meta.url).href;
42
42
  const DEFAULT_FNN_PREFLIGHT_TIMEOUT_MS = 2_500;
43
+ const DEFAULT_PUBLIC_IP_DISCOVERY_URL = "https://api.ipify.org?format=json";
44
+ const DEFAULT_PUBLIC_IP_DISCOVERY_TIMEOUT_MS = 5_000;
43
45
  const DEFAULT_REPORT_URL = "https://ejchwtvdxpyojxsursmh.supabase.co/functions/v1/fibscope-gateway/agent/v1/report";
44
46
  const AGENT_ENV_PATHS = [resolve("agent/.env"), resolve(".env")];
45
47
  const ENV_REPORT_URL = "FIBGATE";
@@ -367,9 +369,8 @@ export async function createRuntime(options = {}) {
367
369
  const statusPath = resolve(options.status ?? DEFAULT_STATUS_PATH);
368
370
  const config = await loadConfig(configPath, options);
369
371
  await ensureFnnPreflight(config, statusPath, options);
372
+ await resolveAgentPublicIdentity(config);
370
373
  const remoteConfig = await fetchAgentConfig(config).catch(() => undefined);
371
- config.observedPublicIp = remoteConfig?.observedPublicIp ?? config.observedPublicIp;
372
- config.observedPeerAddress = remoteConfig?.observedPeerAddress ?? config.observedPeerAddress;
373
374
  if (!config.nodeDisplayName && remoteConfig?.node?.displayName) {
374
375
  config.nodeDisplayName = remoteConfig.node.displayName;
375
376
  }
@@ -481,6 +482,8 @@ export async function collectReport(config, previousReport = undefined) {
481
482
  agentVersion: AGENT_VERSION,
482
483
  chain: config.chain ?? "testnet",
483
484
  collectedAt,
485
+ publicIp: config.publicIp,
486
+ peerAddress: config.publicPeerAddress,
484
487
  node: state.node,
485
488
  peers: state.peers ?? [],
486
489
  channels: state.channels ?? [],
@@ -509,8 +512,8 @@ async function enrichReportNode(node, config) {
509
512
  ...await inferPeerAddressesFromLocalFnn(config),
510
513
  ]);
511
514
  const addresses = publishablePeerAddresses([
512
- config.observedPeerAddress,
513
- buildObservedPeerAddress(config.observedPublicIp, [config.fnnP2pListenAddr, ...candidates]),
515
+ config.publicPeerAddress,
516
+ buildObservedPeerAddress(config.publicIp, [config.fnnP2pListenAddr, ...candidates]),
514
517
  ...candidates,
515
518
  ]);
516
519
  const enriched = {
@@ -1548,12 +1551,111 @@ async function acceptApiNodePayment(runtime, params) {
1548
1551
  const depositTxHash = textValue(params.depositTxHash);
1549
1552
  const deposit = depositTxHash ? await waitForCkbTransaction(config, depositTxHash, params) : undefined;
1550
1553
  const targetPubkey = normalizePeerPubkey(firstTextValue(params.receiverNode, params.targetPubkey, params.destinationNode, params.pubkey));
1551
- const fiberPayment = await sendFiberPayment(config, {
1552
- ...params,
1553
- targetPubkey,
1554
- amountShannons: textValue(params.amountShannons ?? params.amount),
1555
- keysend: true,
1556
- });
1554
+ const peerAddress = firstTextValue(params.peerAddress, params.receiverPeerAddress, params.address);
1555
+ const provisionChannel = boolValue(params.provisionChannel, true);
1556
+ const closeChannelAfterPayment = provisionChannel && boolValue(params.closeChannelAfterPayment, false);
1557
+ let existingChannel;
1558
+ let routeProbe;
1559
+ try {
1560
+ existingChannel = await findReadyChannelForPeer(config.fiberRpcUrl, targetPubkey, config.timeoutMs).catch(() => undefined);
1561
+ try {
1562
+ routeProbe = await simulateFiberRoute(config, {
1563
+ destinationNode: targetPubkey,
1564
+ amountShannons: textValue(params.amountShannons ?? params.amount),
1565
+ assetType: textValue(params.assetType ?? params.asset) || "CKB",
1566
+ });
1567
+ } catch (error) {
1568
+ routeProbe = { status: "failed", error: message(error) };
1569
+ }
1570
+ } catch (error) {
1571
+ routeProbe = { status: "failed", error: message(error) };
1572
+ }
1573
+ const existingRouteReady = routeProbe?.status === "success" || Boolean(existingChannel);
1574
+ let channelOpen;
1575
+ let paymentChannel = existingChannel;
1576
+ let channelBeforePayment;
1577
+ let fiberPayment;
1578
+ try {
1579
+ if (!existingRouteReady) {
1580
+ if (!provisionChannel) {
1581
+ throw new Error(`No viable Fiber route to ${shortText(targetPubkey, 10, 8)} and direct channel provisioning is disabled.`);
1582
+ }
1583
+ if (!peerAddress) {
1584
+ throw new Error(`No viable Fiber route to ${shortText(targetPubkey, 10, 8)} and the receiving node has no public peer address for automatic connection.`);
1585
+ }
1586
+ channelOpen = await openFiberChannel(config, {
1587
+ peerPubkey: targetPubkey,
1588
+ peerAddress,
1589
+ fundingAmount: firstTextValue(params.channelFundingAmount, params.totalDebit, params.amountShannons, params.amount),
1590
+ connectFirst: true,
1591
+ waitUntilReady: true,
1592
+ waitTimeoutMs: positiveNumber(params.channelReadyTimeoutMs, 10 * 60_000),
1593
+ pollMs: positiveNumber(params.channelPollMs, 5_000),
1594
+ public: true,
1595
+ });
1596
+ paymentChannel = channelOpen.channel ?? await findReadyChannelForPeer(config.fiberRpcUrl, targetPubkey, config.timeoutMs);
1597
+ }
1598
+ channelBeforePayment = channelLiquiditySnapshot(paymentChannel);
1599
+ fiberPayment = await sendFiberPayment(config, {
1600
+ ...params,
1601
+ targetPubkey,
1602
+ amountShannons: textValue(params.amountShannons ?? params.amount),
1603
+ keysend: true,
1604
+ });
1605
+ } catch (error) {
1606
+ let refund;
1607
+ let refundError;
1608
+ try {
1609
+ const openedChannelId = channelIdOf(paymentChannel);
1610
+ if (channelOpen && openedChannelId) {
1611
+ await settleFiberChannel(runtime, { channelId: openedChannelId, force: false, feeRate: "1000" });
1612
+ await waitForChannelClosed(config.fiberRpcUrl, openedChannelId, 10 * 60_000, 5_000);
1613
+ }
1614
+ refund = await executeProviderWalletPayout(config, {
1615
+ receiverWallet: textValue(params.senderWallet),
1616
+ amount: textValue(params.totalDebit ?? params.depositAmount),
1617
+ paymentId: textValue(params.paymentId),
1618
+ depositTxHash,
1619
+ });
1620
+ } catch (failure) {
1621
+ refundError = message(failure);
1622
+ }
1623
+ const refundDetail = refund?.txHash
1624
+ ? ` Refund broadcast to the sender: ${refund.txHash}.`
1625
+ : ` Automatic refund also failed: ${refundError ?? "unknown refund error"}.`;
1626
+ throw new Error(`Fiber node delivery failed after deposit confirmation: ${message(error)}.${refundDetail}`);
1627
+ }
1628
+ const paymentChannelId = channelIdOf(paymentChannel);
1629
+ const channelAfterPaymentRaw = paymentChannelId
1630
+ ? await waitForChannelBalanceChange(
1631
+ config.fiberRpcUrl,
1632
+ paymentChannelId,
1633
+ channelBeforePayment,
1634
+ positiveNumber(params.channelBalanceTimeoutMs, 60_000),
1635
+ positiveNumber(params.channelBalancePollMs, 2_000),
1636
+ ).catch(() => undefined)
1637
+ : await findReadyChannelForPeer(config.fiberRpcUrl, targetPubkey, config.timeoutMs).catch(() => undefined);
1638
+ const channelAfterPayment = channelLiquiditySnapshot(channelAfterPaymentRaw);
1639
+ let channelClose;
1640
+ let channelClosed;
1641
+ if (channelOpen && closeChannelAfterPayment && paymentChannelId) {
1642
+ try {
1643
+ channelClose = await settleFiberChannel(runtime, {
1644
+ channelId: paymentChannelId,
1645
+ force: false,
1646
+ feeRate: params.channelCloseFeeRate ?? params.feeRate ?? "1000",
1647
+ });
1648
+ const closed = await waitForChannelClosed(
1649
+ config.fiberRpcUrl,
1650
+ paymentChannelId,
1651
+ positiveNumber(params.channelCloseTimeoutMs, 10 * 60_000),
1652
+ positiveNumber(params.channelPollMs, 5_000),
1653
+ );
1654
+ channelClosed = channelLiquiditySnapshot(closed);
1655
+ } catch (error) {
1656
+ channelClose = { status: "failed", error: message(error) };
1657
+ }
1658
+ }
1557
1659
  let refreshed;
1558
1660
  if (typeof runtime.collect === "function" && typeof runtime.push === "function") {
1559
1661
  refreshed = await pushWithRegistrationRetry(runtime, await runtime.collect(), {
@@ -1585,10 +1687,29 @@ async function acceptApiNodePayment(runtime, params) {
1585
1687
  depositAmount: textValue(params.depositAmount) || undefined,
1586
1688
  deposit,
1587
1689
  fiberPayment,
1690
+ channelLifecycle: {
1691
+ mode: channelOpen ? "provisioned_direct_channel" : existingChannel ? "existing_direct_channel" : "existing_routed_liquidity",
1692
+ provisionRequested: provisionChannel,
1693
+ closeRequested: closeChannelAfterPayment,
1694
+ peer: { pubkey: targetPubkey, address: peerAddress || undefined },
1695
+ routeProbe,
1696
+ channelOpen,
1697
+ channelId: paymentChannelId || undefined,
1698
+ providerBeforePayment: channelBeforePayment?.provider,
1699
+ receiverBeforePayment: channelBeforePayment?.receiver,
1700
+ providerAfterPayment: channelAfterPayment?.provider,
1701
+ receiverAfterPayment: channelAfterPayment?.receiver,
1702
+ channelClose,
1703
+ providerAtClose: channelClosed?.provider,
1704
+ receiverAtClose: channelClosed?.receiver,
1705
+ finalChannelState: channelClosed?.state ?? channelAfterPayment?.state,
1706
+ },
1588
1707
  fundingRefresh: refreshed,
1589
1708
  routeExecution: {
1590
1709
  status: "fiber_send_payment",
1591
- reason: "Fiber node recipients are channel endpoints, so the provider uses outbound Fiber liquidity after confirming the payer deposit.",
1710
+ reason: channelOpen
1711
+ ? "No viable route existed, so the provider connected the receiving peer, opened a direct channel, sent the payment, and applied the requested close policy."
1712
+ : "The provider used existing outbound Fiber liquidity after confirming the payer deposit.",
1592
1713
  },
1593
1714
  node: {
1594
1715
  pubkey: normalizePeerPubkey(readNodeId(info)),
@@ -2047,6 +2168,78 @@ async function findChannelById(rpcUrl, channelId, timeoutMs) {
2047
2168
  return channels.find((channel) => textValue(channel?.channel_id ?? channel?.channelId ?? channel?.id).toLowerCase() === normalized);
2048
2169
  }
2049
2170
 
2171
+ async function findReadyChannelForPeer(rpcUrl, pubkey, timeoutMs) {
2172
+ const response = await rpcCall(rpcUrl, "list_channels", [{ pubkey, include_closed: true }], timeoutMs);
2173
+ const channels = Array.isArray(response?.channels) ? response.channels : Array.isArray(response) ? response : [];
2174
+ return channels.find((channel) => {
2175
+ const state = channelStateName(channel).toLowerCase();
2176
+ return state === "channelready" || state === "open";
2177
+ });
2178
+ }
2179
+
2180
+ async function waitForChannelClosed(rpcUrl, channelId, timeoutMs, pollMs) {
2181
+ const deadline = Date.now() + timeoutMs;
2182
+ let lastChannel;
2183
+ while (Date.now() < deadline) {
2184
+ lastChannel = await findChannelById(rpcUrl, channelId, Math.min(pollMs, 10_000)).catch(() => lastChannel);
2185
+ const state = channelStateName(lastChannel).toLowerCase();
2186
+ if (["closed", "abandoned"].includes(state)) return lastChannel;
2187
+ await sleep(pollMs);
2188
+ }
2189
+ return {
2190
+ ...asObject(lastChannel),
2191
+ channel_id: channelId,
2192
+ close_wait_status: "timeout",
2193
+ close_wait_timeout_ms: timeoutMs,
2194
+ };
2195
+ }
2196
+
2197
+ async function waitForChannelBalanceChange(rpcUrl, channelId, before, timeoutMs, pollMs) {
2198
+ const deadline = Date.now() + timeoutMs;
2199
+ let lastChannel;
2200
+ while (Date.now() < deadline) {
2201
+ lastChannel = await findChannelById(rpcUrl, channelId, Math.min(pollMs, 10_000)).catch(() => lastChannel);
2202
+ const current = channelLiquiditySnapshot(lastChannel);
2203
+ if (!before || channelBalanceChanged(before, current)) return lastChannel;
2204
+ await sleep(pollMs);
2205
+ }
2206
+ return lastChannel;
2207
+ }
2208
+
2209
+ function channelBalanceChanged(before, after) {
2210
+ if (!before || !after) return false;
2211
+ return before.provider.localBalance !== after.provider.localBalance
2212
+ || before.provider.remoteBalance !== after.provider.remoteBalance;
2213
+ }
2214
+
2215
+ function channelIdOf(channel) {
2216
+ return firstTextValue(channel?.channel_id, channel?.channelId, channel?.id);
2217
+ }
2218
+
2219
+ function channelLiquiditySnapshot(channel) {
2220
+ if (!channel || typeof channel !== "object") return undefined;
2221
+ const localBalance = amountString(channel.local_balance ?? channel.localBalance ?? channel.outbound_liquidity ?? channel.outboundLiquidity);
2222
+ const remoteBalance = amountString(channel.remote_balance ?? channel.remoteBalance ?? channel.inbound_liquidity ?? channel.inboundLiquidity);
2223
+ const capacity = amountString(channel.funding_amount ?? channel.fundingAmount ?? channel.capacity);
2224
+ return {
2225
+ channelId: channelIdOf(channel) || undefined,
2226
+ state: channelStateName(channel),
2227
+ capacity,
2228
+ provider: {
2229
+ localBalance,
2230
+ outbound: localBalance,
2231
+ remoteBalance,
2232
+ inbound: remoteBalance,
2233
+ },
2234
+ receiver: {
2235
+ localBalance: remoteBalance,
2236
+ outbound: remoteBalance,
2237
+ remoteBalance: localBalance,
2238
+ inbound: localBalance,
2239
+ },
2240
+ };
2241
+ }
2242
+
2050
2243
  async function assertNotLocalPeer(config, pubkey) {
2051
2244
  try {
2052
2245
  const info = await rpcCall(config.fiberRpcUrl, "node_info", [], config.timeoutMs);
@@ -2294,6 +2487,8 @@ async function loadConfig(configPath, options = {}) {
2294
2487
  ckbIndexerUrl: process.env.FIBSCOPE_AGENT_CKB_INDEXER_URL,
2295
2488
  walletPath: process.env.FIBSCOPE_AGENT_WALLET_PATH ?? envValue(ENV_WALLET_PATH),
2296
2489
  peerAddress: process.env.FIBSCOPE_AGENT_PEER_ADDRESS,
2490
+ publicIp: process.env.FIBSCOPE_AGENT_PUBLIC_IP,
2491
+ publicIpDiscoveryUrl: process.env.FIBSCOPE_AGENT_PUBLIC_IP_URL,
2297
2492
  };
2298
2493
  label = "agent/.env";
2299
2494
  } else if (!existsSync(configPath)) {
@@ -2315,10 +2510,6 @@ function resolveReportUrl(config = {}) {
2315
2510
 
2316
2511
  async function fetchAgentConfig(config) {
2317
2512
  const configUrl = new URL(config.configUrl ?? config.reportUrl.replace(/\/report\/?$/, "/config"));
2318
- configUrl.searchParams.set("p2pPort", String(peerTcpPort([
2319
- config.fnnP2pListenAddr,
2320
- ...configuredPeerAddresses(config),
2321
- ], 8228)));
2322
2513
  const response = await fetch(configUrl, {
2323
2514
  headers: {
2324
2515
  "x-agent-token": config.agentToken,
@@ -2368,6 +2559,8 @@ async function registerAgentNode(config, report, remoteConfig) {
2368
2559
  displayName: config.nodeDisplayName ?? remoteConfig?.node?.displayName,
2369
2560
  publicProfileEnabled: Boolean(report.publicProfileEnabled),
2370
2561
  overwrite: Boolean(config.overwriteNodeRegistration),
2562
+ publicIp: report.publicIp ?? config.publicIp,
2563
+ peerAddress: report.peerAddress ?? config.publicPeerAddress,
2371
2564
  node: report.node ?? {},
2372
2565
  }),
2373
2566
  });
@@ -2505,6 +2698,8 @@ function normalizeConfig(config, label) {
2505
2698
  ckbRpcUrl: process.env.FIBSCOPE_AGENT_CKB_RPC_URL ?? envValue(ENV_CKB_RPC_URL) ?? config.ckbRpcUrl,
2506
2699
  ckbIndexerUrl: process.env.FIBSCOPE_AGENT_CKB_INDEXER_URL ?? config.ckbIndexerUrl,
2507
2700
  walletPath: process.env.FIBSCOPE_AGENT_WALLET_PATH ?? envValue(ENV_WALLET_PATH) ?? config.walletPath,
2701
+ publicIp: process.env.FIBSCOPE_AGENT_PUBLIC_IP ?? config.publicIp,
2702
+ publicIpDiscoveryUrl: process.env.FIBSCOPE_AGENT_PUBLIC_IP_URL ?? config.publicIpDiscoveryUrl,
2508
2703
  pushIntervalMs: positiveNumber(config.pushIntervalMs, DEFAULT_PUSH_INTERVAL_MS),
2509
2704
  commandPollMs: positiveNumber(config.commandPollMs, DEFAULT_COMMAND_POLL_MS),
2510
2705
  knownPeerReconnectMs: positiveNumber(config.knownPeerReconnectMs, DEFAULT_KNOWN_PEER_RECONNECT_MS),
@@ -2516,6 +2711,25 @@ function normalizeConfig(config, label) {
2516
2711
  return normalized;
2517
2712
  }
2518
2713
 
2714
+ async function resolveAgentPublicIdentity(config) {
2715
+ try {
2716
+ const publicIp = await discoverPublicIpv4({
2717
+ publicIp: config.publicIp,
2718
+ endpoint: config.publicIpDiscoveryUrl ?? DEFAULT_PUBLIC_IP_DISCOVERY_URL,
2719
+ timeoutMs: config.publicIpDiscoveryTimeoutMs ?? DEFAULT_PUBLIC_IP_DISCOVERY_TIMEOUT_MS,
2720
+ });
2721
+ config.publicIp = publicIp;
2722
+ config.publicPeerAddress = buildObservedPeerAddress(publicIp, [
2723
+ config.fnnP2pListenAddr,
2724
+ ...configuredPeerAddresses(config),
2725
+ ]);
2726
+ } catch (error) {
2727
+ console.warn(`Fibscope agent public IP unavailable: ${message(error)}`);
2728
+ config.publicIp = undefined;
2729
+ config.publicPeerAddress = publishablePeerAddresses(configuredPeerAddresses(config))[0];
2730
+ }
2731
+ }
2732
+
2519
2733
  async function bindAgentConfigToFnn(config, label, options = {}) {
2520
2734
  const next = { ...config };
2521
2735
  if (options.fiberRpcUrl) {
@@ -5,6 +5,39 @@ export function buildObservedPeerAddress(observedPublicIp, candidates = [], fall
5
5
  return `/ip4/${ip}/tcp/${port}`;
6
6
  }
7
7
 
8
+ export async function discoverPublicIpv4(options = {}) {
9
+ const configured = normalizePublicIpv4(options.publicIp);
10
+ if (configured) return configured;
11
+
12
+ const endpoint = String(options.endpoint ?? "https://api.ipify.org?format=json").trim();
13
+ if (!endpoint) throw new Error("Public IPv4 discovery endpoint is not configured.");
14
+ const timeoutMs = positiveTimeout(options.timeoutMs, 5_000);
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
17
+ try {
18
+ const response = await (options.fetchImpl ?? fetch)(endpoint, {
19
+ headers: { accept: "application/json, text/plain;q=0.9" },
20
+ signal: controller.signal,
21
+ });
22
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
23
+ const body = await response.text();
24
+ let candidate = body;
25
+ try {
26
+ candidate = JSON.parse(body)?.ip ?? body;
27
+ } catch {
28
+ // Configurable plain-text IP endpoints are supported too.
29
+ }
30
+ const publicIp = normalizePublicIpv4(candidate);
31
+ if (!publicIp) throw new Error("response did not contain a public IPv4 address");
32
+ return publicIp;
33
+ } catch (error) {
34
+ const detail = error instanceof Error ? error.message : String(error);
35
+ throw new Error(`Public IPv4 discovery failed at ${endpoint}: ${detail}`);
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
8
41
  export function peerTcpPort(values, fallback = 8228) {
9
42
  for (const value of flatStrings(values)) {
10
43
  const match = value.match(/\/tcp\/(\d{1,5})(?:\/|$)/i);
@@ -52,3 +85,8 @@ function flatStrings(values) {
52
85
  const input = Array.isArray(values) ? values.flat(Infinity) : [values];
53
86
  return input.map((value) => String(value ?? "").trim()).filter(Boolean);
54
87
  }
88
+
89
+ function positiveTimeout(value, fallback) {
90
+ const number = Number(value);
91
+ return Number.isFinite(number) && number > 0 ? number : fallback;
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fibscope/agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Fibscope Agent for Fiber node owners.",
6
6
  "bin": {