blue-js-sdk 2.3.0 → 2.6.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.
Files changed (47) hide show
  1. package/README.md +3 -3
  2. package/batch.js +2 -2
  3. package/chain/authz.js +1 -9
  4. package/chain/fee-grants.js +199 -3
  5. package/chain/index.js +36 -167
  6. package/chain/queries.js +118 -7
  7. package/chain/rpc.js +58 -2
  8. package/client/index.js +1 -3
  9. package/client.js +17 -1
  10. package/connection/connect.js +17 -5
  11. package/connection/disconnect.js +86 -10
  12. package/connection/discovery.js +11 -11
  13. package/connection/index.js +2 -0
  14. package/cosmjs-setup.js +30 -153
  15. package/defaults.js +1 -1
  16. package/index.js +5 -1
  17. package/node-connect.js +118 -25
  18. package/operator.js +2 -0
  19. package/package.json +2 -5
  20. package/pricing/index.js +3 -26
  21. package/types/index.d.ts +2 -2
  22. package/ai-path/ADMIN-ELEVATION.md +0 -116
  23. package/ai-path/AI-MANIFESTO.md +0 -185
  24. package/ai-path/BREAKING.md +0 -74
  25. package/ai-path/CHECKLIST.md +0 -619
  26. package/ai-path/CONNECTION-STEPS.md +0 -724
  27. package/ai-path/DECISION-TREE.md +0 -422
  28. package/ai-path/DEPENDENCIES.md +0 -459
  29. package/ai-path/E2E-FLOW.md +0 -1707
  30. package/ai-path/FAILURES.md +0 -410
  31. package/ai-path/GUIDE.md +0 -1315
  32. package/ai-path/README.md +0 -599
  33. package/ai-path/SPLIT-TUNNEL.md +0 -266
  34. package/ai-path/cli.js +0 -548
  35. package/ai-path/connect.js +0 -1028
  36. package/ai-path/discover.js +0 -178
  37. package/ai-path/environment.js +0 -266
  38. package/ai-path/errors.js +0 -86
  39. package/ai-path/examples/autonomous-agent.mjs +0 -220
  40. package/ai-path/examples/multi-region.mjs +0 -174
  41. package/ai-path/examples/one-shot.mjs +0 -31
  42. package/ai-path/index.js +0 -79
  43. package/ai-path/pricing.js +0 -137
  44. package/ai-path/recommend.js +0 -413
  45. package/ai-path/run-admin.vbs +0 -25
  46. package/ai-path/setup.js +0 -291
  47. package/ai-path/wallet.js +0 -137
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  JavaScript/TypeScript SDK for the [Sentinel](https://sentinel.co) decentralized VPN network. WireGuard + V2Ray tunnels, Cosmos blockchain, 900+ nodes. RPC queries, typed events, CosmJS compatible.
4
4
 
5
- **Also available:** [Blue C# SDK](https://github.com/Sentinel-Autonomybuilder/blue-csharp-sdk) | [Blue AI Connect](https://github.com/Sentinel-Autonomybuilder/blue-ai-connect) (zero-config wrapper for AI agents)
5
+ **Also available:** [Blue C# SDK](https://github.com/Sentinel-Autonomybuilder/blue-csharp-sdk) | [Blue Agent Connect](https://github.com/Sentinel-Autonomybuilder/blue-agent-connect) (zero-config wrapper for AI agents)
6
6
 
7
7
  ## Platform Support
8
8
 
@@ -18,7 +18,7 @@ JavaScript/TypeScript SDK for the [Sentinel](https://sentinel.co) decentralized
18
18
 
19
19
  ---
20
20
 
21
- > **For AI agents:** If you just want `connect()` with one function call, use [`blue-ai-connect`](https://www.npmjs.com/package/blue-ai-connect) instead.
21
+ > **For AI agents:** If you just want `connect()` with one function call, use [`blue-agent-connect`](https://www.npmjs.com/package/blue-agent-connect) instead.
22
22
 
23
23
  ## Install
24
24
 
@@ -46,7 +46,7 @@ await disconnect();
46
46
 
47
47
  ## For AI Agents
48
48
 
49
- Use [sentinel-ai-connect](https://www.npmjs.com/package/sentinel-ai-connect) — a zero-config wrapper with one function call from zero to encrypted tunnel.
49
+ Use [blue-agent-connect](https://www.npmjs.com/package/blue-agent-connect) — a zero-config wrapper with one function call from zero to encrypted tunnel.
50
50
 
51
51
  ## Features
52
52
 
package/batch.js CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { ChainError, ErrorCodes } from './errors.js';
28
- import { sleep } from './defaults.js';
28
+ import { sleep, DEFAULT_LCD } from './defaults.js';
29
29
  import {
30
30
  broadcast,
31
31
  extractAllSessionIds,
@@ -246,7 +246,7 @@ async function _processBatch(client, account, batch, gigabytes, denom, ctx) {
246
246
  export async function waitForBatchSessions(nodeAddrs, walletAddr, lcdUrl, options = {}) {
247
247
  const maxWaitMs = options.maxWaitMs ?? DEFAULT_POLL_TIMEOUT;
248
248
  const pollIntervalMs = options.pollIntervalMs ?? 2000;
249
- const baseLcd = lcdUrl || 'https://lcd.sentinel.co';
249
+ const baseLcd = lcdUrl || DEFAULT_LCD;
250
250
 
251
251
  if (nodeAddrs.length === 0) return { confirmed: [], pending: [] };
252
252
 
package/chain/authz.js CHANGED
@@ -11,7 +11,6 @@
11
11
 
12
12
  import { protoString, protoEmbedded, protoInt64 } from '../v3protocol.js';
13
13
  import { ChainError, ErrorCodes } from '../errors.js';
14
- import { lcd, lcdPaginatedSafe } from './lcd.js';
15
14
  import { buildRegistry } from './client.js';
16
15
 
17
16
  // ─── Protobuf Helpers ───────────────────────────────────────────────────────
@@ -99,11 +98,4 @@ export function encodeForExec(msgs) {
99
98
  });
100
99
  }
101
100
 
102
- /**
103
- * Query authz grants between granter and grantee.
104
- * @returns {Promise<Array>} Array of grant objects
105
- */
106
- export async function queryAuthzGrants(lcdUrl, granter, grantee) {
107
- const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`, 'grants');
108
- return items;
109
- }
101
+ // queryAuthzGrants removed — use RPC-first version from chain/queries.js
@@ -13,7 +13,7 @@ import { EventEmitter } from 'events';
13
13
  import { protoString, protoInt64, protoEmbedded } from '../v3protocol.js';
14
14
  import { LCD_ENDPOINTS } from '../defaults.js';
15
15
  import { ValidationError, ErrorCodes } from '../errors.js';
16
- import { lcd, lcdPaginatedSafe, lcdQueryAll } from './lcd.js';
16
+ import { lcdQuery, lcdPaginatedSafe } from './lcd.js';
17
17
  import { isSameKey } from './wallet.js';
18
18
  import { queryPlanSubscribers } from './queries.js';
19
19
  import {
@@ -178,9 +178,9 @@ export async function queryFeeGrant(lcdUrl, granter, grantee) {
178
178
  }
179
179
  } catch { /* fall through to LCD */ }
180
180
 
181
- // LCD fallback
181
+ // LCD fallback (with endpoint failover via lcdQuery)
182
182
  try {
183
- const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
183
+ const data = await lcdQuery(`/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`, { lcdUrl });
184
184
  return data.allowance || null;
185
185
  } catch { return null; } // 404 = no grant
186
186
  }
@@ -354,3 +354,199 @@ export function monitorFeeGrants(opts = {}) {
354
354
 
355
355
  return emitter;
356
356
  }
357
+
358
+ // ─── Streaming Batch Grant (for SSE / progress UIs) ──────────────────────────
359
+
360
+ /**
361
+ * Stream progress as we grant fee allowances to all plan subscribers in batches.
362
+ *
363
+ * Async generator. Yields events with `{ type, ...payload }`:
364
+ * - status { msg } — human-readable status line
365
+ * - batch_start { batch, total, count, addresses } — about to broadcast a batch
366
+ * - batch_ok { batch, total, granted, totalGranted, txHash, elapsed }
367
+ * - batch_error { batch, total, error, elapsed }
368
+ * - done { granted, skipped, total, errors? }
369
+ * - error { msg }
370
+ *
371
+ * The caller passes a `broadcast(msgs, memo)` function — any safe-broadcaster
372
+ * with the Plan Manager's mutex + sequence-retry semantics works. Consumer
373
+ * routes layer SSE (`res.write('data: ...\n\n')`) on top of these events.
374
+ *
375
+ * @param {number|string} planId
376
+ * @param {object} opts
377
+ * @param {string} opts.granterAddress - Plan owner paying fees
378
+ * @param {string} opts.lcdUrl - LCD endpoint
379
+ * @param {(msgs: Array, memo: string) => Promise<{code:number, rawLog?:string, transactionHash?:string}>} opts.broadcast
380
+ * @param {object} [opts.grantOpts] - { spendLimit, expiration } for BasicAllowance
381
+ * @param {number} [opts.batchSize=5] - Msgs per TX
382
+ * @param {() => boolean} [opts.isCancelled] - Return true to abort between batches
383
+ * @yields {{type: string, [key: string]: any}}
384
+ */
385
+ export async function* streamGrantPlanSubscribers(planId, opts = {}) {
386
+ const {
387
+ granterAddress,
388
+ lcdUrl,
389
+ broadcast,
390
+ grantOpts = {},
391
+ batchSize = 5,
392
+ isCancelled = () => false,
393
+ } = opts;
394
+
395
+ if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
396
+ if (typeof broadcast !== 'function') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'broadcast function is required');
397
+
398
+ try {
399
+ yield { type: 'status', msg: 'Fetching plan subscribers...' };
400
+ const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
401
+
402
+ const now = new Date();
403
+ const activeSubs = subscribers.filter(s => {
404
+ if (s.status && s.status !== 'active') return false;
405
+ if (s.inactive_at && new Date(s.inactive_at) <= now) return false;
406
+ return true;
407
+ });
408
+ const uniqueAddrs = [...new Set(activeSubs.map(s => s.acc_address || s.address))]
409
+ .filter(a => a && a !== granterAddress && !isSameKey(a, granterAddress));
410
+
411
+ yield { type: 'status', msg: `Found ${activeSubs.length} active subscribers (${uniqueAddrs.length} unique, excl. self)` };
412
+
413
+ if (uniqueAddrs.length === 0) {
414
+ yield { type: 'done', granted: 0, skipped: 0, total: 0, msg: 'No active subscribers (excluding self)' };
415
+ return;
416
+ }
417
+
418
+ yield { type: 'status', msg: 'Checking existing grants...' };
419
+ const existing = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
420
+ const existingGrantees = new Set(existing.map(g => g.grantee));
421
+ const needGrant = uniqueAddrs.filter(a => !existingGrantees.has(a));
422
+ const skipped = uniqueAddrs.length - needGrant.length;
423
+
424
+ yield { type: 'status', msg: `${existingGrantees.size} existing grants found. ${needGrant.length} need granting, ${skipped} already covered.` };
425
+
426
+ if (needGrant.length === 0) {
427
+ yield { type: 'done', granted: 0, skipped, total: 0, msg: 'All subscribers already have grants' };
428
+ return;
429
+ }
430
+
431
+ const totalBatches = Math.ceil(needGrant.length / batchSize);
432
+ let granted = 0;
433
+ const errors = [];
434
+
435
+ for (let i = 0; i < needGrant.length; i += batchSize) {
436
+ if (isCancelled()) break;
437
+
438
+ const batchNum = Math.floor(i / batchSize) + 1;
439
+ const batch = needGrant.slice(i, i + batchSize);
440
+ const shortAddrs = batch.map(a => a.slice(0, 12) + '...' + a.slice(-6)).join(', ');
441
+
442
+ yield { type: 'batch_start', batch: batchNum, total: totalBatches, count: batch.length, addresses: shortAddrs };
443
+
444
+ const msgs = batch.map(grantee => buildFeeGrantMsg(granterAddress, grantee, grantOpts));
445
+
446
+ const t0 = Date.now();
447
+ try {
448
+ const result = await broadcast(msgs, `Fee grant batch ${batchNum}/${totalBatches}`);
449
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
450
+
451
+ if (result.code !== 0) {
452
+ const errMsg = result.rawLog || `TX failed code=${result.code}`;
453
+ yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: errMsg, elapsed };
454
+ errors.push(`Batch ${batchNum}: ${errMsg}`);
455
+ } else {
456
+ granted += batch.length;
457
+ yield {
458
+ type: 'batch_ok',
459
+ batch: batchNum,
460
+ total: totalBatches,
461
+ granted: batch.length,
462
+ totalGranted: granted,
463
+ txHash: result.transactionHash,
464
+ elapsed,
465
+ };
466
+ }
467
+ } catch (e) {
468
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
469
+ yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: e.message, elapsed };
470
+ errors.push(`Batch ${batchNum}: ${e.message}`);
471
+ }
472
+ }
473
+
474
+ yield {
475
+ type: 'done',
476
+ granted,
477
+ skipped,
478
+ total: needGrant.length,
479
+ errors: errors.length ? errors : undefined,
480
+ };
481
+ } catch (e) {
482
+ yield { type: 'error', msg: e.message };
483
+ }
484
+ }
485
+
486
+ // ─── Gas Cost Analytics ──────────────────────────────────────────────────────
487
+
488
+ /**
489
+ * Compute how many udvpn the granter has spent on fee-granted transactions
490
+ * for a plan's subscribers. Iterates each subscriber, pulls their outgoing
491
+ * TXs via LCD, and sums fees where `fee.granter === granterAddress`.
492
+ *
493
+ * @param {number|string} planId
494
+ * @param {object} opts
495
+ * @param {string} opts.granterAddress - Address that paid the fees (plan owner)
496
+ * @param {string} opts.lcdUrl - LCD endpoint
497
+ * @param {number} [opts.txLimit=100] - Max TXs to inspect per subscriber
498
+ * @param {(info: {processed:number, total:number, address:string}) => void} [opts.onProgress]
499
+ * @returns {Promise<{ totalUdvpn: number, txCount: number, byAddress: Record<string, {udvpn:number, txCount:number}>, subscriberCount: number }>}
500
+ */
501
+ export async function computeFeeGrantGasCosts(planId, opts = {}) {
502
+ const { granterAddress, lcdUrl, txLimit = 100, onProgress } = opts;
503
+ if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
504
+
505
+ const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
506
+ const subscriberAddrs = [...new Set(subscribers.map(s => s.acc_address || s.address))]
507
+ .filter(a => a && a !== granterAddress);
508
+
509
+ if (subscriberAddrs.length === 0) {
510
+ return { totalUdvpn: 0, txCount: 0, byAddress: {}, subscriberCount: 0 };
511
+ }
512
+
513
+ let totalUdvpn = 0;
514
+ let txCount = 0;
515
+ const byAddress = {};
516
+
517
+ const base = lcdUrl || LCD_ENDPOINTS[0].url;
518
+ for (let idx = 0; idx < subscriberAddrs.length; idx++) {
519
+ const addr = subscriberAddrs[idx];
520
+ try {
521
+ const path =
522
+ `/cosmos/tx/v1beta1/txs?events=${encodeURIComponent("message.sender='" + addr + "'")}` +
523
+ `&pagination.limit=${txLimit}&order_by=2`;
524
+ const txData = await lcdQuery(path, { lcdUrl: base });
525
+ const rawTxs = txData.txs || [];
526
+
527
+ let addrGas = 0;
528
+ let addrTxCount = 0;
529
+
530
+ for (const tx of rawTxs) {
531
+ const fee = tx?.auth_info?.fee;
532
+ if (fee?.granter === granterAddress) {
533
+ const udvpnFee = (fee.amount || []).find(f => f.denom === 'udvpn');
534
+ if (udvpnFee) {
535
+ addrGas += parseInt(udvpnFee.amount, 10);
536
+ addrTxCount++;
537
+ }
538
+ }
539
+ }
540
+
541
+ if (addrTxCount > 0) {
542
+ byAddress[addr] = { udvpn: addrGas, txCount: addrTxCount };
543
+ totalUdvpn += addrGas;
544
+ txCount += addrTxCount;
545
+ }
546
+ } catch { /* skip this subscriber on LCD failure */ }
547
+
548
+ if (onProgress) onProgress({ processed: idx + 1, total: subscriberAddrs.length, address: addr });
549
+ }
550
+
551
+ return { totalUdvpn, txCount, byAddress, subscriberCount: subscriberAddrs.length };
552
+ }
package/chain/index.js CHANGED
@@ -50,6 +50,19 @@ import { publicEndpointAgent } from '../security/index.js';
50
50
  // Wallet — validation helpers (used by chain functions that validate addresses)
51
51
  import { validateMnemonic, validateAddress } from '../wallet/index.js';
52
52
 
53
+ // RPC-first query modules — delegates LCD-only functions to these
54
+ import {
55
+ findExistingSession as _rpcFindExistingSession,
56
+ fetchActiveNodes as _rpcFetchActiveNodes,
57
+ getNetworkOverview as _rpcGetNetworkOverview,
58
+ getNodePrices as _rpcGetNodePrices,
59
+ discoverPlanIds as _rpcDiscoverPlanIds,
60
+ } from './queries.js';
61
+ import {
62
+ queryFeeGrants as _rpcQueryFeeGrants,
63
+ queryFeeGrant as _rpcQueryFeeGrant,
64
+ } from './fee-grants.js';
65
+
53
66
  // ─── All Type URL Constants ──────────────────────────────────────────────────
54
67
 
55
68
  export const MSG_TYPES = {
@@ -345,19 +358,16 @@ export function txResponse(result) {
345
358
  /**
346
359
  * Find an existing active session for a wallet+node pair.
347
360
  * Returns session ID (BigInt) or null. Use this to avoid double-paying.
361
+ * Delegates to chain/queries.js RPC-first implementation.
348
362
  *
349
- * Note: Sessions have a nested base_session object containing the actual data.
363
+ * @param {string} lcdUrl - LCD endpoint URL
364
+ * @param {string} walletAddr - sent1... wallet address
365
+ * @param {string} nodeAddr - sentnode1... node address
366
+ * @param {object} [opts] - Optional. Pass `{ onStaleDuplicate: (BigInt) => void }` to
367
+ * receive fire-and-forget cancellation callbacks for stale duplicate sessions.
350
368
  */
351
- export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
352
- const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1&pagination.limit=100`);
353
- for (const s of (data.sessions || [])) {
354
- const bs = s.base_session || s; // session data is nested in base_session
355
- if (bs.node_address !== nodeAddr) continue;
356
- const maxBytes = parseInt(bs.max_bytes || '0');
357
- const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
358
- if (maxBytes === 0 || used < maxBytes) return BigInt(bs.id);
359
- }
360
- return null;
369
+ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts) {
370
+ return _rpcFindExistingSession(lcdUrl, walletAddr, nodeAddr, opts);
361
371
  }
362
372
 
363
373
  /**
@@ -377,176 +387,44 @@ export function resolveNodeUrl(node) {
377
387
  }
378
388
 
379
389
  /**
380
- * Fetch all active nodes from LCD with pagination.
390
+ * Fetch all active nodes with RPC-first + LCD fallback.
381
391
  * Returns array of node objects. Each node has `remote_url` resolved from `remote_addrs`.
392
+ * Delegates to chain/queries.js RPC-first implementation.
382
393
  */
383
394
  export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
384
- const nodes = [];
385
- let nextKey = null;
386
- let page = 0;
387
- do {
388
- const keyParam = nextKey ? `&pagination.key=${encodeURIComponent(nextKey)}` : '';
389
- const data = await lcd(lcdUrl, `/sentinel/node/v3/nodes?status=1&pagination.limit=${limit}${keyParam}`);
390
- nodes.push(...(data.nodes || []));
391
- nextKey = data.pagination?.next_key || null;
392
- page++;
393
- } while (nextKey && page < maxPages);
394
- // Add computed remote_url for backward compatibility
395
- for (const n of nodes) {
396
- try { n.remote_url = resolveNodeUrl(n); } catch { /* skip nodes with no address */ }
397
- }
398
- return nodes;
395
+ return _rpcFetchActiveNodes(lcdUrl, limit, maxPages);
399
396
  }
400
397
 
401
398
  /**
402
399
  * Get a quick network overview — total nodes, counts by country and service type, average prices.
403
- * Perfect for dashboard UIs, onboarding screens, and network health displays.
400
+ * Delegates to chain/queries.js RPC-first implementation.
404
401
  *
405
402
  * @param {string} [lcdUrl] - LCD endpoint (default: cascading fallback)
406
403
  * @returns {Promise<{ totalNodes: number, byCountry: Array<{country: string, count: number}>, byType: {wireguard: number, v2ray: number, unknown: number}, averagePrice: {gigabyteDvpn: number, hourlyDvpn: number}, nodes: Array }>}
407
- *
408
- * @example
409
- * const overview = await getNetworkOverview();
410
- * console.log(`${overview.totalNodes} nodes across ${overview.byCountry.length} countries`);
411
- * console.log(`Average: ${overview.averagePrice.gigabyteDvpn.toFixed(3)} P2P/GB`);
412
404
  */
413
405
  export async function getNetworkOverview(lcdUrl) {
414
- const fetchFn = async (url) => fetchActiveNodes(url);
415
- let nodes;
416
- if (lcdUrl) {
417
- nodes = await fetchFn(lcdUrl);
418
- } else {
419
- const result = await tryWithFallback(LCD_ENDPOINTS, fetchFn, 'getNetworkOverview');
420
- nodes = result.result;
421
- }
422
-
423
- // Filter to nodes that accept udvpn
424
- const active = nodes.filter(n => n.remote_url && (n.gigabyte_prices || []).some(p => p.denom === 'udvpn'));
425
-
426
- // Count by country (from LCD metadata, limited — enrichNodes gives better data)
427
- const countryMap = {};
428
- for (const n of active) {
429
- const c = n.location?.country || n.country || 'Unknown';
430
- countryMap[c] = (countryMap[c] || 0) + 1;
431
- }
432
- const byCountry = Object.entries(countryMap)
433
- .map(([country, count]) => ({ country, count }))
434
- .sort((a, b) => b.count - a.count);
435
-
436
- // Count by type (type not in LCD — estimate from service_type field if present)
437
- const byType = { wireguard: 0, v2ray: 0, unknown: 0 };
438
- for (const n of active) {
439
- const t = n.service_type || n.type;
440
- if (t === 'wireguard' || t === 1) byType.wireguard++;
441
- else if (t === 'v2ray' || t === 2) byType.v2ray++;
442
- else byType.unknown++;
443
- }
444
-
445
- // Average prices
446
- let gbTotal = 0, gbCount = 0, hrTotal = 0, hrCount = 0;
447
- for (const n of active) {
448
- const gb = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
449
- if (gb?.quote_value) { gbTotal += parseInt(gb.quote_value, 10); gbCount++; }
450
- const hr = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
451
- if (hr?.quote_value) { hrTotal += parseInt(hr.quote_value, 10); hrCount++; }
452
- }
453
-
454
- return {
455
- totalNodes: active.length,
456
- byCountry,
457
- byType,
458
- averagePrice: {
459
- gigabyteDvpn: gbCount > 0 ? (gbTotal / gbCount) / 1_000_000 : 0,
460
- hourlyDvpn: hrCount > 0 ? (hrTotal / hrCount) / 1_000_000 : 0,
461
- },
462
- nodes: active,
463
- };
406
+ return _rpcGetNetworkOverview(lcdUrl);
464
407
  }
465
408
 
466
409
  /**
467
410
  * Discover plan IDs by probing subscription endpoints.
468
- * Workaround for /sentinel/plan/v3/plans returning 501 Not Implemented.
411
+ * Delegates to chain/queries.js RPC-first implementation.
469
412
  * Returns sorted array of plan IDs that have at least 1 subscription.
470
413
  */
471
414
  export async function discoverPlanIds(lcdUrl, maxId = 100) {
472
- const ids = [];
473
- const batchSize = 10;
474
- for (let batch = 0; batch < maxId / batchSize; batch++) {
475
- const checks = [];
476
- for (let i = batch * batchSize + 1; i <= (batch + 1) * batchSize; i++) {
477
- checks.push(
478
- lcd(lcdUrl, `/sentinel/subscription/v3/plans/${i}/subscriptions?pagination.limit=1&pagination.count_total=true`)
479
- .then(d => { if (parseInt(d.pagination?.total || '0') > 0) ids.push(i); })
480
- .catch(() => {})
481
- );
482
- }
483
- await Promise.all(checks);
484
- }
485
- return ids.sort((a, b) => a - b);
415
+ return _rpcDiscoverPlanIds(lcdUrl, maxId);
486
416
  }
487
417
 
488
418
  /**
489
- * Get standardized prices for a node — abstracts V3 LCD price parsing entirely.
490
- *
491
- * Solves the common "NaN / GB" problem by defensively extracting quote_value,
492
- * base_value, or amount from the nested LCD response structure.
419
+ * Get standardized prices for a node — abstracts V3 price parsing entirely.
420
+ * Delegates to chain/queries.js RPC-first implementation (direct node lookup, not full-list scan).
493
421
  *
494
422
  * @param {string} nodeAddress - sentnode1... address
495
423
  * @param {string} [lcdUrl] - LCD endpoint URL (default: cascading fallback across all endpoints)
496
424
  * @returns {Promise<{ gigabyte: { dvpn: number, udvpn: number, raw: object|null }, hourly: { dvpn: number, udvpn: number, raw: object|null }, denom: string, nodeAddress: string }>}
497
- *
498
- * @example
499
- * const prices = await getNodePrices('sentnode1abc...');
500
- * console.log(`${prices.gigabyte.dvpn} P2P/GB, ${prices.hourly.dvpn} P2P/hr`);
501
- * // Use prices.gigabyte.raw for the full { denom, base_value, quote_value } object
502
- * // needed by encodeMsgStartSession's max_price field.
503
425
  */
504
426
  export async function getNodePrices(nodeAddress, lcdUrl) {
505
- if (typeof nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(nodeAddress)) {
506
- throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (46 characters)', { value: nodeAddress });
507
- }
508
-
509
- const fetchNode = async (baseUrl) => {
510
- let nextKey = null;
511
- let pages = 0;
512
- do {
513
- const keyParam = nextKey ? `&pagination.key=${encodeURIComponent(nextKey)}` : '';
514
- const data = await lcd(baseUrl, `/sentinel/node/v3/nodes?status=1&pagination.limit=500${keyParam}`);
515
- const nodes = data.nodes || [];
516
- const found = nodes.find(n => n.address === nodeAddress);
517
- if (found) return found;
518
- nextKey = data.pagination?.next_key || null;
519
- pages++;
520
- } while (nextKey && pages < 20);
521
- return null;
522
- };
523
-
524
- let node;
525
- if (lcdUrl) {
526
- node = await fetchNode(lcdUrl);
527
- } else {
528
- const result = await tryWithFallback(LCD_ENDPOINTS, fetchNode, 'getNodePrices');
529
- node = result.result;
530
- }
531
-
532
- if (!node) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found on LCD (may be inactive or deregistered)`, { nodeAddress });
533
-
534
- function extractPrice(priceArray) {
535
- if (!Array.isArray(priceArray)) return { dvpn: 0, udvpn: 0, raw: null };
536
- const entry = priceArray.find(p => p.denom === 'udvpn');
537
- if (!entry) return { dvpn: 0, udvpn: 0, raw: null };
538
- // Defensive fallback chain: quote_value (V3 current) -> base_value -> amount (legacy)
539
- const rawVal = entry.quote_value || entry.base_value || entry.amount || '0';
540
- const udvpn = parseInt(rawVal, 10) || 0;
541
- return { dvpn: parseFloat((udvpn / 1_000_000).toFixed(6)), udvpn, raw: entry };
542
- }
543
-
544
- return {
545
- gigabyte: extractPrice(node.gigabyte_prices),
546
- hourly: extractPrice(node.hourly_prices),
547
- denom: 'P2P',
548
- nodeAddress,
549
- };
427
+ return _rpcGetNodePrices(nodeAddress, lcdUrl);
550
428
  }
551
429
 
552
430
  // ─── Display & Serialization Helpers ────────────────────────────────────────
@@ -761,22 +639,20 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
761
639
 
762
640
  /**
763
641
  * Query fee grants given to a grantee.
642
+ * Delegates to chain/fee-grants.js RPC-first implementation.
764
643
  * @returns {Promise<Array>} Array of allowance objects
765
644
  */
766
645
  export async function queryFeeGrants(lcdUrl, grantee) {
767
- const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`);
768
- return data.allowances || [];
646
+ return _rpcQueryFeeGrants(lcdUrl, grantee);
769
647
  }
770
648
 
771
649
  /**
772
650
  * Query a specific fee grant between granter and grantee.
651
+ * Delegates to chain/fee-grants.js RPC-first implementation.
773
652
  * @returns {Promise<object|null>} Allowance object or null
774
653
  */
775
654
  export async function queryFeeGrant(lcdUrl, granter, grantee) {
776
- try {
777
- const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
778
- return data.allowance || null;
779
- } catch { return null; } // 404 = no grant
655
+ return _rpcQueryFeeGrant(lcdUrl, granter, grantee);
780
656
  }
781
657
 
782
658
  /**
@@ -878,14 +754,7 @@ export function encodeForExec(msgs) {
878
754
  });
879
755
  }
880
756
 
881
- /**
882
- * Query authz grants between granter and grantee.
883
- * @returns {Promise<Array>} Array of grant objects
884
- */
885
- export async function queryAuthzGrants(lcdUrl, granter, grantee) {
886
- const data = await lcd(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`);
887
- return data.grants || [];
888
- }
757
+ // queryAuthzGrants removed — use RPC-first version from chain/queries.js
889
758
 
890
759
  // Re-export extractSessionId for convenience (from protocol module)
891
760
  export { extractSessionId };