blue-js-sdk 2.1.1 → 2.3.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
 
@@ -472,18 +505,7 @@ export async function getBalance(client, address) {
472
505
  * Note: Sessions have a nested base_session object containing the actual data.
473
506
  */
474
507
  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;
508
+ return _findExistingSession(lcdUrl, walletAddr, nodeAddr);
487
509
  }
488
510
 
489
511
  /**
@@ -493,13 +515,7 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
493
515
  * This handles both formats.
494
516
  */
495
517
  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}`;
518
+ return _resolveNodeUrl(node);
503
519
  }
504
520
 
505
521
  /**
@@ -507,11 +523,7 @@ export function resolveNodeUrl(node) {
507
523
  * Returns array of node objects. Each node has `remote_url` resolved from `remote_addrs`.
508
524
  */
509
525
  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;
526
+ return _fetchActiveNodes(lcdUrl, limit, maxPages);
515
527
  }
516
528
 
517
529
  /**
@@ -527,55 +539,7 @@ export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
527
539
  * console.log(`Average: ${overview.averagePrice.gigabyteDvpn.toFixed(3)} P2P/GB`);
528
540
  */
529
541
  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
- };
542
+ return _getNetworkOverview(lcdUrl);
579
543
  }
580
544
 
581
545
  /**
@@ -584,9 +548,7 @@ export async function getNetworkOverview(lcdUrl) {
584
548
  * Returns sorted array of plan IDs that have at least 1 subscription.
585
549
  */
586
550
  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);
551
+ return _discoverPlanIds(lcdUrl, maxId);
590
552
  }
591
553
 
592
554
  /**
@@ -606,29 +568,7 @@ export async function discoverPlanIds(lcdUrl, maxId = 500) {
606
568
  * // needed by encodeMsgStartSession's max_price field.
607
569
  */
608
570
  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
- };
571
+ return _getNodePrices(nodeAddress, lcdUrl);
632
572
  }
633
573
 
634
574
  // ─── Display & Serialization Helpers ────────────────────────────────────────
@@ -861,8 +801,7 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
861
801
  * @returns {Promise<Array>} Array of allowance objects
862
802
  */
863
803
  export async function queryFeeGrants(lcdUrl, grantee) {
864
- const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`, 'allowances');
865
- return items;
804
+ return _queryFeeGrants(lcdUrl, grantee);
866
805
  }
867
806
 
868
807
  /**
@@ -872,8 +811,7 @@ export async function queryFeeGrants(lcdUrl, grantee) {
872
811
  * @returns {Promise<Array>}
873
812
  */
874
813
  export async function queryFeeGrantsIssued(lcdUrl, granter) {
875
- const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/issued/${granter}`, 'allowances');
876
- return items;
814
+ return _queryFeeGrantsIssued(lcdUrl, granter);
877
815
  }
878
816
 
879
817
  /**
@@ -881,10 +819,7 @@ export async function queryFeeGrantsIssued(lcdUrl, granter) {
881
819
  * @returns {Promise<object|null>} Allowance object or null
882
820
  */
883
821
  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
822
+ return _queryFeeGrant(lcdUrl, granter, grantee);
888
823
  }
889
824
 
890
825
  /**
@@ -995,8 +930,7 @@ export function encodeForExec(msgs) {
995
930
  * @returns {Promise<Array>} Array of grant objects
996
931
  */
997
932
  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;
933
+ return _queryAuthzGrants(lcdUrl, granter, grantee);
1000
934
  }
1001
935
 
1002
936
  // ─── LCD Query Helpers (v25b) ────────────────────────────────────────────────
@@ -1096,20 +1030,7 @@ export async function lcdQueryAll(basePath, opts = {}) {
1096
1030
  * @returns {Promise<{ subscribers: Array<{ address: string, status: number, id: string }>, total: number|null }>}
1097
1031
  */
1098
1032
  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 };
1033
+ return _queryPlanSubscribers(planId, opts);
1113
1034
  }
1114
1035
 
1115
1036
  /**
@@ -1122,14 +1043,7 @@ export async function queryPlanSubscribers(planId, opts = {}) {
1122
1043
  * @returns {Promise<{ subscriberCount: number, totalOnChain: number, ownerSubscribed: boolean }>}
1123
1044
  */
1124
1045
  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
- };
1046
+ return _getPlanStats(planId, ownerAddress, opts);
1133
1047
  }
1134
1048
 
1135
1049
  // ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
@@ -1146,39 +1060,7 @@ export async function getPlanStats(planId, ownerAddress, opts = {}) {
1146
1060
  * @returns {Promise<{ msgs: Array, skipped: string[], newGrants: string[] }>} Messages ready for broadcast
1147
1061
  */
1148
1062
  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 };
1063
+ return _grantPlanSubscribers(planId, opts);
1182
1064
  }
1183
1065
 
1184
1066
  /**
@@ -1191,34 +1073,7 @@ export async function grantPlanSubscribers(planId, opts = {}) {
1191
1073
  * @returns {Promise<Array<{ granter: string, grantee: string, expiresAt: Date|null, daysLeft: number|null }>>}
1192
1074
  */
1193
1075
  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;
1076
+ return _getExpiringGrants(lcdUrl, granteeOrGranter, withinDays, role);
1222
1077
  }
1223
1078
 
1224
1079
  /**
@@ -1231,18 +1086,7 @@ export async function getExpiringGrants(lcdUrl, granteeOrGranter, withinDays = 7
1231
1086
  * @returns {Promise<{ msgs: Array, renewed: string[] }>} Messages ready for broadcast
1232
1087
  */
1233
1088
  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 };
1089
+ return _renewExpiringGrants(lcdUrl, granterAddress, withinDays, grantOpts);
1246
1090
  }
1247
1091
 
1248
1092
  // ─── Fee Grant Monitoring (v25b) ─────────────────────────────────────────────
@@ -1260,46 +1104,7 @@ export async function renewExpiringGrants(lcdUrl, granterAddress, withinDays = 7
1260
1104
  * @returns {EventEmitter} Emits 'expiring' and 'expired' events. Call .stop() to stop monitoring.
1261
1105
  */
1262
1106
  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;
1107
+ return _monitorFeeGrants(opts);
1303
1108
  }
1304
1109
 
1305
1110
  // ─── Query Helpers (v25c) ────────────────────────────────────────────────────
@@ -1311,10 +1116,7 @@ export function monitorFeeGrants(opts = {}) {
1311
1116
  * @returns {Promise<{ subscriptions: any[], total: number|null }>}
1312
1117
  */
1313
1118
  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' });
1119
+ return _querySubscriptions(lcdUrl, walletAddr, opts);
1318
1120
  }
1319
1121
 
1320
1122
  /**
@@ -1324,20 +1126,7 @@ export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
1324
1126
  * @returns {Promise<{ maxBytes: number, usedBytes: number, remainingBytes: number, percentUsed: number }|null>}
1325
1127
  */
1326
1128
  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; }
1129
+ return _querySessionAllocation(lcdUrl, sessionId);
1341
1130
  }
1342
1131
 
1343
1132
  /**
@@ -1350,28 +1139,7 @@ export async function querySessionAllocation(lcdUrl, sessionId) {
1350
1139
  * @returns {Promise<object>} Node object with remote_url resolved
1351
1140
  */
1352
1141
  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;
1142
+ return _queryNode(nodeAddress, opts);
1375
1143
  }
1376
1144
 
1377
1145
  /**
@@ -1467,12 +1235,7 @@ export async function lcdPaginatedSafe(lcdUrl, path, itemsKey, opts = {}) {
1467
1235
  * @returns {Promise<{ items: ChainSession[], total: number }>}
1468
1236
  */
1469
1237
  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;
1238
+ return _querySessions(address, lcdUrl, opts);
1476
1239
  }
1477
1240
 
1478
1241
  /**
@@ -1484,27 +1247,7 @@ export async function querySessions(address, lcdUrl, opts = {}) {
1484
1247
  * @returns {object} Flattened session with all fields at top level
1485
1248
  */
1486
1249
  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
- };
1250
+ return _flattenSession(session);
1508
1251
  }
1509
1252
 
1510
1253
  /**
@@ -1514,10 +1257,7 @@ export function flattenSession(session) {
1514
1257
  * @returns {Promise<Subscription|null>}
1515
1258
  */
1516
1259
  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; }
1260
+ return _querySubscription(id, lcdUrl);
1521
1261
  }
1522
1262
 
1523
1263
  /**
@@ -1528,10 +1268,7 @@ export async function querySubscription(id, lcdUrl) {
1528
1268
  * @returns {Promise<{ has: boolean, subscription?: object }>}
1529
1269
  */
1530
1270
  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 };
1271
+ return _hasActiveSubscription(address, planId, lcdUrl);
1535
1272
  }
1536
1273
 
1537
1274
  // ─── v26c: Display Helpers ───────────────────────────────────────────────────
@@ -1572,15 +1309,7 @@ export function parseChainDuration(durationStr) {
1572
1309
  * @returns {Promise<{ items: any[], total: number|null }>}
1573
1310
  */
1574
1311
  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;
1312
+ return _queryPlanNodes(planId, lcdUrl);
1584
1313
  }
1585
1314
 
1586
1315
  /**
@@ -1595,33 +1324,7 @@ export async function queryPlanNodes(planId, lcdUrl) {
1595
1324
  * @returns {Promise<Array<{ id: number, subscribers: number, nodeCount: number, price: object|null, hasNodes: boolean }>>}
1596
1325
  */
1597
1326
  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);
1327
+ return _discoverPlans(lcdUrl, opts);
1625
1328
  }
1626
1329
 
1627
1330
  /**
@@ -1709,10 +1412,7 @@ export async function subscribeToPlan(client, fromAddress, planId, denom = 'udvp
1709
1412
  * @returns {Promise<object|null>}
1710
1413
  */
1711
1414
  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; }
1415
+ return _getProviderByAddress(provAddress, opts);
1716
1416
  }
1717
1417
 
1718
1418
  /**
@@ -1904,7 +1604,6 @@ export const MSG_TYPES = {
1904
1604
  // v27: Persistent user settings (backported from C# VpnSettings.cs).
1905
1605
  // Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.
1906
1606
 
1907
- const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
1908
1607
 
1909
1608
  /**
1910
1609
  * Load persisted VPN settings from disk.
@@ -1912,10 +1611,7 @@ const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
1912
1611
  * @returns {Record<string, any>}
1913
1612
  */
1914
1613
  export function loadVpnSettings() {
1915
- try {
1916
- if (!existsSync(SETTINGS_FILE)) return {};
1917
- return JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8'));
1918
- } catch { return {}; }
1614
+ return _loadVpnSettings();
1919
1615
  }
1920
1616
 
1921
1617
  /**
@@ -1923,7 +1619,5 @@ export function loadVpnSettings() {
1923
1619
  * @param {Record<string, any>} settings
1924
1620
  */
1925
1621
  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 });
1622
+ return _saveVpnSettings(settings);
1929
1623
  }