blue-js-sdk 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cosmjs-setup.js CHANGED
@@ -52,9 +52,42 @@ import {
52
52
  } from './plan-operations.js';
53
53
  import { GAS_PRICE, RPC_ENDPOINTS, LCD_ENDPOINTS, tryWithFallback } from './defaults.js';
54
54
  import { ValidationError, NodeError, ChainError, ErrorCodes } from './errors.js';
55
- import path from 'path';
56
- import os from 'os';
57
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
55
+ // path, os, fs imports removed — settings persistence now in chain/queries.js
56
+
57
+ // RPC-first query layer cosmjs-setup.js delegates query functions here
58
+ import {
59
+ findExistingSession as _findExistingSession,
60
+ fetchActiveNodes as _fetchActiveNodes,
61
+ getNetworkOverview as _getNetworkOverview,
62
+ queryNode as _queryNode,
63
+ resolveNodeUrl as _resolveNodeUrl,
64
+ querySubscriptions as _querySubscriptions,
65
+ querySessionAllocation as _querySessionAllocation,
66
+ querySessions as _querySessions,
67
+ flattenSession as _flattenSession,
68
+ querySubscription as _querySubscription,
69
+ hasActiveSubscription as _hasActiveSubscription,
70
+ queryPlanNodes as _queryPlanNodes,
71
+ discoverPlans as _discoverPlans,
72
+ discoverPlanIds as _discoverPlanIds,
73
+ getNodePrices as _getNodePrices,
74
+ getProviderByAddress as _getProviderByAddress,
75
+ queryPlanSubscribers as _queryPlanSubscribers,
76
+ getPlanStats as _getPlanStats,
77
+ querySubscriptionAllocations as _querySubscriptionAllocations,
78
+ queryAuthzGrants as _queryAuthzGrants,
79
+ loadVpnSettings as _loadVpnSettings,
80
+ saveVpnSettings as _saveVpnSettings,
81
+ } from './chain/queries.js';
82
+ import {
83
+ queryFeeGrants as _queryFeeGrants,
84
+ queryFeeGrantsIssued as _queryFeeGrantsIssued,
85
+ queryFeeGrant as _queryFeeGrant,
86
+ grantPlanSubscribers as _grantPlanSubscribers,
87
+ getExpiringGrants as _getExpiringGrants,
88
+ renewExpiringGrants as _renewExpiringGrants,
89
+ monitorFeeGrants as _monitorFeeGrants,
90
+ } from './chain/fee-grants.js';
58
91
 
59
92
  // ─── Input Validation Helpers ────────────────────────────────────────────────
60
93
 
@@ -429,29 +462,11 @@ export function extractId(txResult, eventPattern, keyNames) {
429
462
  }
430
463
 
431
464
  // ─── LCD Query Helper ────────────────────────────────────────────────────────
465
+ // Canonical LCD functions live in chain/lcd.js. Re-export for backward compatibility.
432
466
 
433
467
  import axios from 'axios';
434
- import { publicEndpointAgent } from './tls-trust.js';
435
468
 
436
- /**
437
- * Query a Sentinel LCD REST endpoint.
438
- * Checks both HTTP status AND gRPC error codes in response body.
439
- * Uses CA-validated HTTPS for LCD public infrastructure (valid CA certs).
440
- *
441
- * Usage:
442
- * const data = await lcd('https://lcd.sentinel.co', '/sentinel/node/v3/nodes?status=1');
443
- */
444
- export async function lcd(baseUrl, path) {
445
- // Accept Endpoint objects ({ url, name }) or bare strings
446
- const base = typeof baseUrl === 'object' ? baseUrl.url : baseUrl;
447
- const url = `${base}${path}`;
448
- const res = await axios.get(url, { httpsAgent: publicEndpointAgent, timeout: 15000 });
449
- const data = res.data;
450
- if (data?.code && data.code !== 0) {
451
- throw new ChainError(ErrorCodes.LCD_ERROR, `LCD ${path}: code=${data.code} ${data.message || ''}`, { path, code: data.code, message: data.message });
452
- }
453
- return data;
454
- }
469
+ export { lcd, lcdQuery, lcdQueryAll, lcdPaginatedSafe } from './chain/lcd.js';
455
470
 
456
471
  // ─── Query Helpers ───────────────────────────────────────────────────────────
457
472
 
@@ -472,18 +487,7 @@ export async function getBalance(client, address) {
472
487
  * Note: Sessions have a nested base_session object containing the actual data.
473
488
  */
474
489
  export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
475
- const { items } = await lcdPaginatedSafe(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`, 'sessions');
476
- for (const s of items) {
477
- const bs = s.base_session || s;
478
- if ((bs.node_address || bs.node) !== nodeAddr) continue;
479
- if (bs.status && bs.status !== 'active') continue;
480
- const acct = bs.acc_address || bs.address;
481
- if (acct && acct !== walletAddr) continue;
482
- const maxBytes = parseInt(bs.max_bytes || '0');
483
- const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
484
- if (maxBytes === 0 || used < maxBytes) return BigInt(bs.id);
485
- }
486
- return null;
490
+ return _findExistingSession(lcdUrl, walletAddr, nodeAddr);
487
491
  }
488
492
 
489
493
  /**
@@ -493,13 +497,7 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
493
497
  * This handles both formats.
494
498
  */
495
499
  export function resolveNodeUrl(node) {
496
- // Try legacy field first (string with https://)
497
- if (node.remote_url && typeof node.remote_url === 'string') return node.remote_url;
498
- // v3 LCD: remote_addrs is an array of "IP:PORT" strings
499
- const addrs = node.remote_addrs || [];
500
- const raw = addrs.find(a => a.includes(':')) || addrs[0];
501
- if (!raw) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${node.address} has no remote_addrs`, { address: node.address });
502
- return raw.startsWith('http') ? raw : `https://${raw}`;
500
+ return _resolveNodeUrl(node);
503
501
  }
504
502
 
505
503
  /**
@@ -507,11 +505,7 @@ export function resolveNodeUrl(node) {
507
505
  * Returns array of node objects. Each node has `remote_url` resolved from `remote_addrs`.
508
506
  */
509
507
  export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
510
- const { items } = await lcdPaginatedSafe(lcdUrl, '/sentinel/node/v3/nodes?status=1', 'nodes', { limit });
511
- for (const n of items) {
512
- try { n.remote_url = resolveNodeUrl(n); } catch { /* skip nodes with no address */ }
513
- }
514
- return items;
508
+ return _fetchActiveNodes(lcdUrl, limit, maxPages);
515
509
  }
516
510
 
517
511
  /**
@@ -527,55 +521,7 @@ export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
527
521
  * console.log(`Average: ${overview.averagePrice.gigabyteDvpn.toFixed(3)} P2P/GB`);
528
522
  */
529
523
  export async function getNetworkOverview(lcdUrl) {
530
- let nodes;
531
- if (lcdUrl) {
532
- nodes = await fetchActiveNodes(lcdUrl);
533
- } else {
534
- const result = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, 'getNetworkOverview');
535
- nodes = result.result;
536
- }
537
-
538
- // Filter to nodes that accept udvpn
539
- const active = nodes.filter(n => n.remote_url && (n.gigabyte_prices || []).some(p => p.denom === 'udvpn'));
540
-
541
- // Count by country (from LCD metadata, limited — enrichNodes gives better data)
542
- const countryMap = {};
543
- for (const n of active) {
544
- const c = n.location?.country || n.country || 'Unknown';
545
- countryMap[c] = (countryMap[c] || 0) + 1;
546
- }
547
- const byCountry = Object.entries(countryMap)
548
- .map(([country, count]) => ({ country, count }))
549
- .sort((a, b) => b.count - a.count);
550
-
551
- // Count by type (type not in LCD — estimate from service_type field if present)
552
- const byType = { wireguard: 0, v2ray: 0, unknown: 0 };
553
- for (const n of active) {
554
- const t = n.service_type || n.type;
555
- if (t === 'wireguard' || t === 1) byType.wireguard++;
556
- else if (t === 'v2ray' || t === 2) byType.v2ray++;
557
- else byType.unknown++;
558
- }
559
-
560
- // Average prices
561
- let gbTotal = 0, gbCount = 0, hrTotal = 0, hrCount = 0;
562
- for (const n of active) {
563
- const gb = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
564
- if (gb?.quote_value) { gbTotal += parseInt(gb.quote_value, 10); gbCount++; }
565
- const hr = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
566
- if (hr?.quote_value) { hrTotal += parseInt(hr.quote_value, 10); hrCount++; }
567
- }
568
-
569
- return {
570
- totalNodes: active.length,
571
- byCountry,
572
- byType,
573
- averagePrice: {
574
- gigabyteDvpn: gbCount > 0 ? (gbTotal / gbCount) / 1_000_000 : 0,
575
- hourlyDvpn: hrCount > 0 ? (hrTotal / hrCount) / 1_000_000 : 0,
576
- },
577
- nodes: active,
578
- };
524
+ return _getNetworkOverview(lcdUrl);
579
525
  }
580
526
 
581
527
  /**
@@ -584,9 +530,7 @@ export async function getNetworkOverview(lcdUrl) {
584
530
  * Returns sorted array of plan IDs that have at least 1 subscription.
585
531
  */
586
532
  export async function discoverPlanIds(lcdUrl, maxId = 500) {
587
- // Delegates to discoverPlans and extracts just the IDs
588
- const plans = await discoverPlans(lcdUrl, { maxId });
589
- return plans.map(p => p.id);
533
+ return _discoverPlanIds(lcdUrl, maxId);
590
534
  }
591
535
 
592
536
  /**
@@ -606,29 +550,7 @@ export async function discoverPlanIds(lcdUrl, maxId = 500) {
606
550
  * // needed by encodeMsgStartSession's max_price field.
607
551
  */
608
552
  export async function getNodePrices(nodeAddress, lcdUrl) {
609
- if (typeof nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(nodeAddress)) {
610
- throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (46 characters)', { value: nodeAddress });
611
- }
612
-
613
- // Reuse queryNode() instead of duplicating pagination
614
- const node = await queryNode(nodeAddress, { lcdUrl });
615
-
616
- function extractPrice(priceArray) {
617
- if (!Array.isArray(priceArray)) return { dvpn: 0, udvpn: 0, raw: null };
618
- const entry = priceArray.find(p => p.denom === 'udvpn');
619
- if (!entry) return { dvpn: 0, udvpn: 0, raw: null };
620
- // Defensive fallback chain: quote_value (V3 current) → base_value → amount (legacy)
621
- const rawVal = entry.quote_value || entry.base_value || entry.amount || '0';
622
- const udvpn = parseInt(rawVal, 10) || 0;
623
- return { dvpn: parseFloat((udvpn / 1_000_000).toFixed(6)), udvpn, raw: entry };
624
- }
625
-
626
- return {
627
- gigabyte: extractPrice(node.gigabyte_prices),
628
- hourly: extractPrice(node.hourly_prices),
629
- denom: 'P2P',
630
- nodeAddress,
631
- };
553
+ return _getNodePrices(nodeAddress, lcdUrl);
632
554
  }
633
555
 
634
556
  // ─── Display & Serialization Helpers ────────────────────────────────────────
@@ -861,8 +783,7 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
861
783
  * @returns {Promise<Array>} Array of allowance objects
862
784
  */
863
785
  export async function queryFeeGrants(lcdUrl, grantee) {
864
- const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`, 'allowances');
865
- return items;
786
+ return _queryFeeGrants(lcdUrl, grantee);
866
787
  }
867
788
 
868
789
  /**
@@ -872,8 +793,7 @@ export async function queryFeeGrants(lcdUrl, grantee) {
872
793
  * @returns {Promise<Array>}
873
794
  */
874
795
  export async function queryFeeGrantsIssued(lcdUrl, granter) {
875
- const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/issued/${granter}`, 'allowances');
876
- return items;
796
+ return _queryFeeGrantsIssued(lcdUrl, granter);
877
797
  }
878
798
 
879
799
  /**
@@ -881,10 +801,7 @@ export async function queryFeeGrantsIssued(lcdUrl, granter) {
881
801
  * @returns {Promise<object|null>} Allowance object or null
882
802
  */
883
803
  export async function queryFeeGrant(lcdUrl, granter, grantee) {
884
- try {
885
- const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
886
- return data.allowance || null;
887
- } catch { return null; } // 404 = no grant
804
+ return _queryFeeGrant(lcdUrl, granter, grantee);
888
805
  }
889
806
 
890
807
  /**
@@ -995,94 +912,10 @@ export function encodeForExec(msgs) {
995
912
  * @returns {Promise<Array>} Array of grant objects
996
913
  */
997
914
  export async function queryAuthzGrants(lcdUrl, granter, grantee) {
998
- const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`, 'grants');
999
- return items;
915
+ return _queryAuthzGrants(lcdUrl, granter, grantee);
1000
916
  }
1001
917
 
1002
- // ─── LCD Query Helpers (v25b) ────────────────────────────────────────────────
1003
- // General-purpose LCD query with timeout, retry, error wrapping, and pagination.
1004
-
1005
- /**
1006
- * Single LCD query with timeout, single retry on network error, and ChainError wrapping.
1007
- * Uses the fallback endpoint list if no lcdUrl is provided.
1008
- *
1009
- * @param {string} path - LCD path (e.g. '/sentinel/node/v3/nodes?status=1')
1010
- * @param {object} [opts]
1011
- * @param {string} [opts.lcdUrl] - Specific LCD endpoint (or uses fallback chain)
1012
- * @param {number} [opts.timeout] - Request timeout in ms (default: 15000)
1013
- * @returns {Promise<any>} Parsed JSON response
1014
- */
1015
- export async function lcdQuery(path, opts = {}) {
1016
- const timeout = opts.timeout || 15000;
1017
- const doQuery = async (baseUrl) => {
1018
- try {
1019
- return await lcd(baseUrl, path);
1020
- } catch (err) {
1021
- // Single retry on network error
1022
- if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.message?.includes('timeout')) {
1023
- await new Promise(r => setTimeout(r, 1000));
1024
- return await lcd(baseUrl, path);
1025
- }
1026
- throw err;
1027
- }
1028
- };
1029
-
1030
- if (opts.lcdUrl) {
1031
- return doQuery(opts.lcdUrl);
1032
- }
1033
- const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD ${path}`);
1034
- return result;
1035
- }
1036
-
1037
- /**
1038
- * Auto-paginating LCD query. Fetches all pages via next_key, returns all results + chain total.
1039
- *
1040
- * @param {string} basePath - LCD path without pagination params (e.g. '/sentinel/node/v3/nodes?status=1')
1041
- * @param {object} [opts]
1042
- * @param {string} [opts.lcdUrl] - Specific LCD endpoint (or uses fallback chain)
1043
- * @param {number} [opts.limit] - Page size (default: 200)
1044
- * @param {number} [opts.timeout] - Per-page timeout (default: 15000)
1045
- * @param {string} [opts.dataKey] - Key for the results array in response (default: auto-detect first array)
1046
- * @returns {Promise<{ items: any[], total: number|null }>}
1047
- */
1048
- export async function lcdQueryAll(basePath, opts = {}) {
1049
- const limit = opts.limit || 200;
1050
- const dataKey = opts.dataKey || null;
1051
-
1052
- const fetchAll = async (baseUrl) => {
1053
- let allItems = [];
1054
- let nextKey = null;
1055
- let chainTotal = null;
1056
- let isFirst = true;
1057
- do {
1058
- const sep = basePath.includes('?') ? '&' : '?';
1059
- let url = `${basePath}${sep}pagination.limit=${limit}`;
1060
- if (isFirst) url += '&pagination.count_total=true';
1061
- if (nextKey) url += `&pagination.key=${encodeURIComponent(nextKey)}`;
1062
- const data = await lcd(baseUrl, url);
1063
- if (isFirst && data.pagination?.total) {
1064
- chainTotal = parseInt(data.pagination.total, 10);
1065
- }
1066
- // Auto-detect data key: first array property that isn't 'pagination'
1067
- const key = dataKey || Object.keys(data).find(k => k !== 'pagination' && Array.isArray(data[k]));
1068
- const pageItems = key ? (data[key] || []) : [];
1069
- allItems = allItems.concat(pageItems);
1070
- nextKey = data.pagination?.next_key || null;
1071
- isFirst = false;
1072
- } while (nextKey);
1073
-
1074
- if (chainTotal && allItems.length !== chainTotal) {
1075
- console.warn(`[lcdQueryAll] Pagination mismatch: got ${allItems.length}, chain reports ${chainTotal}`);
1076
- }
1077
- return { items: allItems, total: chainTotal };
1078
- };
1079
-
1080
- if (opts.lcdUrl) {
1081
- return fetchAll(opts.lcdUrl);
1082
- }
1083
- const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchAll, `LCD paginated ${basePath}`);
1084
- return result;
1085
- }
918
+ // LCD Query Helpers canonical implementations in chain/lcd.js, re-exported above.
1086
919
 
1087
920
  // ─── Plan Subscriber Helpers (v25b) ──────────────────────────────────────────
1088
921
 
@@ -1096,20 +929,7 @@ export async function lcdQueryAll(basePath, opts = {}) {
1096
929
  * @returns {Promise<{ subscribers: Array<{ address: string, status: number, id: string }>, total: number|null }>}
1097
930
  */
1098
931
  export async function queryPlanSubscribers(planId, opts = {}) {
1099
- const { items, total } = await lcdQueryAll(
1100
- `/sentinel/subscription/v3/plans/${planId}/subscriptions`,
1101
- { lcdUrl: opts.lcdUrl, dataKey: 'subscriptions' },
1102
- );
1103
- let subscribers = items.map(s => ({
1104
- address: s.address || s.subscriber,
1105
- status: s.status,
1106
- id: s.id || s.base_id,
1107
- ...s,
1108
- }));
1109
- if (opts.excludeAddress) {
1110
- subscribers = subscribers.filter(s => s.address !== opts.excludeAddress);
1111
- }
1112
- return { subscribers, total };
932
+ return _queryPlanSubscribers(planId, opts);
1113
933
  }
1114
934
 
1115
935
  /**
@@ -1122,14 +942,7 @@ export async function queryPlanSubscribers(planId, opts = {}) {
1122
942
  * @returns {Promise<{ subscriberCount: number, totalOnChain: number, ownerSubscribed: boolean }>}
1123
943
  */
1124
944
  export async function getPlanStats(planId, ownerAddress, opts = {}) {
1125
- const { subscribers, total } = await queryPlanSubscribers(planId, { lcdUrl: opts.lcdUrl });
1126
- const ownerSubscribed = subscribers.some(s => s.address === ownerAddress);
1127
- const filtered = subscribers.filter(s => s.address !== ownerAddress);
1128
- return {
1129
- subscriberCount: filtered.length,
1130
- totalOnChain: total,
1131
- ownerSubscribed,
1132
- };
945
+ return _getPlanStats(planId, ownerAddress, opts);
1133
946
  }
1134
947
 
1135
948
  // ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
@@ -1146,39 +959,7 @@ export async function getPlanStats(planId, ownerAddress, opts = {}) {
1146
959
  * @returns {Promise<{ msgs: Array, skipped: string[], newGrants: string[] }>} Messages ready for broadcast
1147
960
  */
1148
961
  export async function grantPlanSubscribers(planId, opts = {}) {
1149
- const { granterAddress, lcdUrl, grantOpts = {} } = opts;
1150
- if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
1151
-
1152
- // Get subscribers
1153
- const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
1154
-
1155
- // Get existing grants ISSUED BY granter (not grants received)
1156
- const existingGrants = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
1157
- const alreadyGranted = new Set(existingGrants.map(g => g.grantee));
1158
-
1159
- const msgs = [];
1160
- const skipped = [];
1161
- const newGrants = [];
1162
-
1163
- const now = new Date();
1164
- // Deduplicate by address and filter active+non-expired
1165
- const seen = new Set();
1166
- for (const sub of subscribers) {
1167
- const addr = sub.acc_address || sub.address;
1168
- if (!addr || seen.has(addr)) continue;
1169
- seen.add(addr);
1170
- // Skip self-grant (chain rejects granter === grantee)
1171
- if (addr === granterAddress || isSameKey(addr, granterAddress)) { skipped.push(addr); continue; }
1172
- // Skip inactive or expired
1173
- if (sub.status && sub.status !== 'active') { skipped.push(addr); continue; }
1174
- if (sub.inactive_at && new Date(sub.inactive_at) <= now) { skipped.push(addr); continue; }
1175
- // Skip already granted
1176
- if (alreadyGranted.has(addr)) { skipped.push(addr); continue; }
1177
- msgs.push(buildFeeGrantMsg(granterAddress, addr, grantOpts));
1178
- newGrants.push(addr);
1179
- }
1180
-
1181
- return { msgs, skipped, newGrants };
962
+ return _grantPlanSubscribers(planId, opts);
1182
963
  }
1183
964
 
1184
965
  /**
@@ -1191,34 +972,7 @@ export async function grantPlanSubscribers(planId, opts = {}) {
1191
972
  * @returns {Promise<Array<{ granter: string, grantee: string, expiresAt: Date|null, daysLeft: number|null }>>}
1192
973
  */
1193
974
  export async function getExpiringGrants(lcdUrl, granteeOrGranter, withinDays = 7, role = 'grantee') {
1194
- const grants = role === 'grantee'
1195
- ? await queryFeeGrants(lcdUrl, granteeOrGranter)
1196
- : await queryFeeGrantsIssued(lcdUrl, granteeOrGranter);
1197
-
1198
- const now = Date.now();
1199
- const cutoff = now + withinDays * 24 * 60 * 60_000;
1200
- const expiring = [];
1201
-
1202
- for (const g of grants) {
1203
- // Fee grant allowances have complex nested @type structures:
1204
- // BasicAllowance: { expiration }
1205
- // PeriodicAllowance: { basic: { expiration } }
1206
- // AllowedMsgAllowance: { allowance: { expiration } or allowance: { basic: { expiration } } }
1207
- const a = g.allowance || {};
1208
- const inner = a.allowance || a; // unwrap AllowedMsgAllowance
1209
- const expStr = inner.expiration || inner.basic?.expiration || a.expiration || a.basic?.expiration;
1210
- if (!expStr) continue; // no expiry set
1211
- const expiresAt = new Date(expStr);
1212
- if (expiresAt.getTime() <= cutoff) {
1213
- expiring.push({
1214
- granter: g.granter,
1215
- grantee: g.grantee,
1216
- expiresAt,
1217
- daysLeft: Math.max(0, Math.round((expiresAt.getTime() - now) / (24 * 60 * 60_000))),
1218
- });
1219
- }
1220
- }
1221
- return expiring;
975
+ return _getExpiringGrants(lcdUrl, granteeOrGranter, withinDays, role);
1222
976
  }
1223
977
 
1224
978
  /**
@@ -1231,18 +985,7 @@ export async function getExpiringGrants(lcdUrl, granteeOrGranter, withinDays = 7
1231
985
  * @returns {Promise<{ msgs: Array, renewed: string[] }>} Messages ready for broadcast
1232
986
  */
1233
987
  export async function renewExpiringGrants(lcdUrl, granterAddress, withinDays = 7, grantOpts = {}) {
1234
- const expiring = await getExpiringGrants(lcdUrl, granterAddress, withinDays, 'granter');
1235
- const msgs = [];
1236
- const renewed = [];
1237
-
1238
- for (const g of expiring) {
1239
- if (g.grantee === granterAddress) continue; // skip self
1240
- msgs.push(buildRevokeFeeGrantMsg(granterAddress, g.grantee));
1241
- msgs.push(buildFeeGrantMsg(granterAddress, g.grantee, grantOpts));
1242
- renewed.push(g.grantee);
1243
- }
1244
-
1245
- return { msgs, renewed };
988
+ return _renewExpiringGrants(lcdUrl, granterAddress, withinDays, grantOpts);
1246
989
  }
1247
990
 
1248
991
  // ─── Fee Grant Monitoring (v25b) ─────────────────────────────────────────────
@@ -1260,46 +1003,7 @@ export async function renewExpiringGrants(lcdUrl, granterAddress, withinDays = 7
1260
1003
  * @returns {EventEmitter} Emits 'expiring' and 'expired' events. Call .stop() to stop monitoring.
1261
1004
  */
1262
1005
  export function monitorFeeGrants(opts = {}) {
1263
- const { lcdUrl, address, checkIntervalMs = 6 * 60 * 60_000, warnDays = 7, autoRenew = false, grantOpts = {} } = opts;
1264
- if (!lcdUrl || !address) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'monitorFeeGrants requires lcdUrl and address');
1265
-
1266
- const emitter = new EventEmitter();
1267
- let timer = null;
1268
-
1269
- const check = async () => {
1270
- try {
1271
- const expiring = await getExpiringGrants(lcdUrl, address, warnDays, 'granter');
1272
- const now = Date.now();
1273
-
1274
- for (const g of expiring) {
1275
- if (g.expiresAt.getTime() <= now) {
1276
- emitter.emit('expired', g);
1277
- } else {
1278
- emitter.emit('expiring', g);
1279
- }
1280
- }
1281
-
1282
- if (autoRenew && expiring.length > 0) {
1283
- const { msgs, renewed } = await renewExpiringGrants(lcdUrl, address, warnDays, grantOpts);
1284
- if (msgs.length > 0) {
1285
- emitter.emit('renew', { msgs, renewed });
1286
- }
1287
- }
1288
- } catch (err) {
1289
- emitter.emit('error', err);
1290
- }
1291
- };
1292
-
1293
- // Start checking
1294
- check();
1295
- timer = setInterval(check, checkIntervalMs);
1296
- if (timer.unref) timer.unref(); // Don't prevent process exit
1297
-
1298
- emitter.stop = () => {
1299
- if (timer) { clearInterval(timer); timer = null; }
1300
- };
1301
-
1302
- return emitter;
1006
+ return _monitorFeeGrants(opts);
1303
1007
  }
1304
1008
 
1305
1009
  // ─── Query Helpers (v25c) ────────────────────────────────────────────────────
@@ -1311,10 +1015,7 @@ export function monitorFeeGrants(opts = {}) {
1311
1015
  * @returns {Promise<{ subscriptions: any[], total: number|null }>}
1312
1016
  */
1313
1017
  export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
1314
- // v26: Correct LCD endpoint for wallet subscriptions
1315
- let path = `/sentinel/subscription/v3/accounts/${walletAddr}/subscriptions`;
1316
- if (opts.status) path += `?status=${opts.status === 'active' ? '1' : '2'}`;
1317
- return lcdQueryAll(path, { lcdUrl, dataKey: 'subscriptions' });
1018
+ return _querySubscriptions(lcdUrl, walletAddr, opts);
1318
1019
  }
1319
1020
 
1320
1021
  /**
@@ -1324,20 +1025,7 @@ export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
1324
1025
  * @returns {Promise<{ maxBytes: number, usedBytes: number, remainingBytes: number, percentUsed: number }|null>}
1325
1026
  */
1326
1027
  export async function querySessionAllocation(lcdUrl, sessionId) {
1327
- try {
1328
- const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
1329
- const s = data.session?.base_session || data.session || {};
1330
- const maxBytes = parseInt(s.max_bytes || '0', 10);
1331
- const dl = parseInt(s.download_bytes || '0', 10);
1332
- const ul = parseInt(s.upload_bytes || '0', 10);
1333
- const usedBytes = dl + ul;
1334
- return {
1335
- maxBytes,
1336
- usedBytes,
1337
- remainingBytes: Math.max(0, maxBytes - usedBytes),
1338
- percentUsed: maxBytes > 0 ? Math.round((usedBytes / maxBytes) * 100) : 0,
1339
- };
1340
- } catch { return null; }
1028
+ return _querySessionAllocation(lcdUrl, sessionId);
1341
1029
  }
1342
1030
 
1343
1031
  /**
@@ -1350,28 +1038,7 @@ export async function querySessionAllocation(lcdUrl, sessionId) {
1350
1038
  * @returns {Promise<object>} Node object with remote_url resolved
1351
1039
  */
1352
1040
  export async function queryNode(nodeAddress, opts = {}) {
1353
- if (typeof nodeAddress !== 'string' || !nodeAddress.startsWith('sentnode1')) {
1354
- throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be sentnode1...', { nodeAddress });
1355
- }
1356
-
1357
- const fetchDirect = async (baseUrl) => {
1358
- try {
1359
- const data = await lcdQuery(`/sentinel/node/v3/nodes/${nodeAddress}`, { lcdUrl: baseUrl });
1360
- if (data?.node) {
1361
- data.node.remote_url = resolveNodeUrl(data.node);
1362
- return data.node;
1363
- }
1364
- } catch { /* fall through to full list */ }
1365
- const { items } = await lcdPaginatedSafe(baseUrl, '/sentinel/node/v3/nodes?status=1', 'nodes');
1366
- const found = items.find(n => n.address === nodeAddress);
1367
- if (!found) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found on LCD (may be inactive)`, { nodeAddress });
1368
- found.remote_url = resolveNodeUrl(found);
1369
- return found;
1370
- };
1371
-
1372
- if (opts.lcdUrl) return fetchDirect(opts.lcdUrl);
1373
- const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `LCD node lookup ${nodeAddress}`);
1374
- return result;
1041
+ return _queryNode(nodeAddress, opts);
1375
1042
  }
1376
1043
 
1377
1044
  /**
@@ -1408,53 +1075,7 @@ export function buildEndSessionMsg(from, sessionId) {
1408
1075
  };
1409
1076
  }
1410
1077
 
1411
- // ─── v26c: Defensive Pagination ──────────────────────────────────────────────
1412
-
1413
- /**
1414
- * Paginated LCD query that handles Sentinel's broken pagination.
1415
- * Tries next_key first. If next_key is null but we got exactly `limit` results
1416
- * (suggesting truncation), falls back to a single large request.
1417
- *
1418
- * @param {string} lcdUrl - LCD base URL
1419
- * @param {string} path - Endpoint path (e.g. '/sentinel/node/v3/plans/36/nodes')
1420
- * @param {string} itemsKey - Response array key ('nodes', 'subscriptions', 'sessions')
1421
- * @param {object} [opts]
1422
- * @param {number} [opts.limit=500] - Page size for paginated requests
1423
- * @param {number} [opts.fallbackLimit=5000] - Single-request limit if pagination broken
1424
- * @returns {Promise<{ items: any[], total: number }>}
1425
- */
1426
- export async function lcdPaginatedSafe(lcdUrl, path, itemsKey, opts = {}) {
1427
- const limit = opts.limit || 500;
1428
- const fallbackLimit = opts.fallbackLimit || 5000;
1429
- const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
1430
- const sep = path.includes('?') ? '&' : '?';
1431
-
1432
- const firstPage = await lcd(baseLcd, `${path}${sep}pagination.limit=${limit}`);
1433
- const firstItems = firstPage[itemsKey] || [];
1434
- const nextKey = firstPage.pagination?.next_key;
1435
-
1436
- // Fewer than limit = that's everything
1437
- if (firstItems.length < limit) {
1438
- return { items: firstItems, total: firstItems.length };
1439
- }
1440
-
1441
- // next_key exists = pagination works, follow it
1442
- if (nextKey) {
1443
- let allItems = [...firstItems];
1444
- let key = nextKey;
1445
- while (key) {
1446
- const page = await lcd(baseLcd, `${path}${sep}pagination.limit=${limit}&pagination.key=${encodeURIComponent(key)}`);
1447
- allItems.push(...(page[itemsKey] || []));
1448
- key = page.pagination?.next_key || null;
1449
- }
1450
- return { items: allItems, total: allItems.length };
1451
- }
1452
-
1453
- // next_key null but hit limit = broken pagination. Single large request.
1454
- const fullData = await lcd(baseLcd, `${path}${sep}pagination.limit=${fallbackLimit}`);
1455
- const allItems = fullData[itemsKey] || [];
1456
- return { items: allItems, total: allItems.length };
1457
- }
1078
+ // lcdPaginatedSafe canonical implementation in chain/lcd.js, re-exported above.
1458
1079
 
1459
1080
  // ─── v26c: Session & Subscription Queries ────────────────────────────────────
1460
1081
 
@@ -1467,12 +1088,7 @@ export async function lcdPaginatedSafe(lcdUrl, path, itemsKey, opts = {}) {
1467
1088
  * @returns {Promise<{ items: ChainSession[], total: number }>}
1468
1089
  */
1469
1090
  export async function querySessions(address, lcdUrl, opts = {}) {
1470
- let path = `/sentinel/session/v3/sessions?address=${address}`;
1471
- if (opts.status) path += `&status=${opts.status}`;
1472
- const result = await lcdPaginatedSafe(lcdUrl, path, 'sessions');
1473
- // Auto-flatten base_session nesting so devs don't hit session.id === undefined
1474
- result.items = result.items.map(flattenSession);
1475
- return result;
1091
+ return _querySessions(address, lcdUrl, opts);
1476
1092
  }
1477
1093
 
1478
1094
  /**
@@ -1484,27 +1100,7 @@ export async function querySessions(address, lcdUrl, opts = {}) {
1484
1100
  * @returns {object} Flattened session with all fields at top level
1485
1101
  */
1486
1102
  export function flattenSession(session) {
1487
- if (!session) return session;
1488
- const bs = session.base_session || {};
1489
- return {
1490
- id: bs.id || session.id,
1491
- acc_address: bs.acc_address || session.acc_address,
1492
- node_address: bs.node_address || bs.node || session.node_address,
1493
- download_bytes: bs.download_bytes || session.download_bytes || '0',
1494
- upload_bytes: bs.upload_bytes || session.upload_bytes || '0',
1495
- max_bytes: bs.max_bytes || session.max_bytes || '0',
1496
- duration: bs.duration || session.duration,
1497
- max_duration: bs.max_duration || session.max_duration,
1498
- status: bs.status || session.status,
1499
- start_at: bs.start_at || session.start_at,
1500
- status_at: bs.status_at || session.status_at,
1501
- inactive_at: bs.inactive_at || session.inactive_at,
1502
- // Preserve type-specific fields
1503
- price: session.price || undefined,
1504
- subscription_id: session.subscription_id || undefined,
1505
- '@type': session['@type'] || undefined,
1506
- _raw: session, // original for advanced use
1507
- };
1103
+ return _flattenSession(session);
1508
1104
  }
1509
1105
 
1510
1106
  /**
@@ -1514,10 +1110,7 @@ export function flattenSession(session) {
1514
1110
  * @returns {Promise<Subscription|null>}
1515
1111
  */
1516
1112
  export async function querySubscription(id, lcdUrl) {
1517
- try {
1518
- const data = await lcdQuery(`/sentinel/subscription/v3/subscriptions/${id}`, { lcdUrl });
1519
- return data.subscription || null;
1520
- } catch { return null; }
1113
+ return _querySubscription(id, lcdUrl);
1521
1114
  }
1522
1115
 
1523
1116
  /**
@@ -1528,10 +1121,7 @@ export async function querySubscription(id, lcdUrl) {
1528
1121
  * @returns {Promise<{ has: boolean, subscription?: object }>}
1529
1122
  */
1530
1123
  export async function hasActiveSubscription(address, planId, lcdUrl) {
1531
- const { items } = await querySubscriptions(lcdUrl, address, { status: 'active' });
1532
- const match = items.find(s => String(s.plan_id) === String(planId));
1533
- if (match) return { has: true, subscription: match };
1534
- return { has: false };
1124
+ return _hasActiveSubscription(address, planId, lcdUrl);
1535
1125
  }
1536
1126
 
1537
1127
  // ─── v26c: Display Helpers ───────────────────────────────────────────────────
@@ -1572,15 +1162,7 @@ export function parseChainDuration(durationStr) {
1572
1162
  * @returns {Promise<{ items: any[], total: number|null }>}
1573
1163
  */
1574
1164
  export async function queryPlanNodes(planId, lcdUrl) {
1575
- // LCD pagination is BROKEN on this endpoint — count_total returns min(actual, limit)
1576
- // and next_key is always null. Single high-limit request gets all nodes.
1577
- const doQuery = async (baseUrl) => {
1578
- const data = await lcd(baseUrl, `/sentinel/node/v3/plans/${planId}/nodes?pagination.limit=5000`);
1579
- return { items: data.nodes || [], total: (data.nodes || []).length };
1580
- };
1581
- if (lcdUrl) return doQuery(lcdUrl);
1582
- const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD plan ${planId} nodes`);
1583
- return result;
1165
+ return _queryPlanNodes(planId, lcdUrl);
1584
1166
  }
1585
1167
 
1586
1168
  /**
@@ -1595,33 +1177,7 @@ export async function queryPlanNodes(planId, lcdUrl) {
1595
1177
  * @returns {Promise<Array<{ id: number, subscribers: number, nodeCount: number, price: object|null, hasNodes: boolean }>>}
1596
1178
  */
1597
1179
  export async function discoverPlans(lcdUrl, opts = {}) {
1598
- const maxId = opts.maxId || 500;
1599
- const batchSize = opts.batchSize || 15;
1600
- const includeEmpty = opts.includeEmpty || false;
1601
- const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
1602
- const plans = [];
1603
-
1604
- for (let batch = 0; batch < Math.ceil(maxId / batchSize); batch++) {
1605
- const probes = [];
1606
- for (let i = batch * batchSize + 1; i <= Math.min((batch + 1) * batchSize, maxId); i++) {
1607
- probes.push((async (id) => {
1608
- try {
1609
- const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
1610
- const subCount = parseInt(subData.pagination?.total || '0', 10);
1611
- if (subCount === 0 && !includeEmpty) return null;
1612
- // Plan nodes endpoint has broken pagination (count_total wrong, next_key null).
1613
- // Use limit=5000 single request and count the actual array.
1614
- const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
1615
- const nodeCount = (nodeData.nodes || []).length;
1616
- const price = subData.subscriptions?.[0]?.price || null;
1617
- return { id, subscribers: subCount, nodeCount, price, hasNodes: nodeCount > 0 };
1618
- } catch { return null; }
1619
- })(i));
1620
- }
1621
- const results = await Promise.all(probes);
1622
- for (const r of results) if (r) plans.push(r);
1623
- }
1624
- return plans.sort((a, b) => a.id - b.id);
1180
+ return _discoverPlans(lcdUrl, opts);
1625
1181
  }
1626
1182
 
1627
1183
  /**
@@ -1709,10 +1265,7 @@ export async function subscribeToPlan(client, fromAddress, planId, denom = 'udvp
1709
1265
  * @returns {Promise<object|null>}
1710
1266
  */
1711
1267
  export async function getProviderByAddress(provAddress, opts = {}) {
1712
- try {
1713
- const data = await lcdQuery(`/sentinel/provider/v2/providers/${provAddress}`, opts);
1714
- return data.provider || null;
1715
- } catch { return null; }
1268
+ return _getProviderByAddress(provAddress, opts);
1716
1269
  }
1717
1270
 
1718
1271
  /**
@@ -1904,7 +1457,6 @@ export const MSG_TYPES = {
1904
1457
  // v27: Persistent user settings (backported from C# VpnSettings.cs).
1905
1458
  // Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.
1906
1459
 
1907
- const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
1908
1460
 
1909
1461
  /**
1910
1462
  * Load persisted VPN settings from disk.
@@ -1912,10 +1464,7 @@ const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
1912
1464
  * @returns {Record<string, any>}
1913
1465
  */
1914
1466
  export function loadVpnSettings() {
1915
- try {
1916
- if (!existsSync(SETTINGS_FILE)) return {};
1917
- return JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8'));
1918
- } catch { return {}; }
1467
+ return _loadVpnSettings();
1919
1468
  }
1920
1469
 
1921
1470
  /**
@@ -1923,7 +1472,5 @@ export function loadVpnSettings() {
1923
1472
  * @param {Record<string, any>} settings
1924
1473
  */
1925
1474
  export function saveVpnSettings(settings) {
1926
- const dir = path.dirname(SETTINGS_FILE);
1927
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
1928
- writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
1475
+ return _saveVpnSettings(settings);
1929
1476
  }