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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  Every fix made during SDK creation, why it matters, and what happens if you use upstream Sentinel code directly without these fixes.
4
4
 
5
+ ## v2.3.0 — RPC-First Migration (2026-04-14)
6
+
7
+ **100% of chain queries now use RPC-first with LCD fallback.** Protobuf/ABCI queries via Tendermint37Client are ~912x faster than LCD REST. If RPC fails, every query automatically falls back to LCD.
8
+
9
+ ### JS SDK Changes
10
+ - **chain/rpc.js**: Added 4 new RPC functions — `rpcQueryFeeGrants`, `rpcQueryFeeGrantsIssued`, `rpcQueryAuthzGrants`, `rpcQueryProvider`
11
+ - **chain/queries.js**: All 22 query functions are RPC-first with LCD fallback
12
+ - **chain/fee-grants.js**: All 7 functions are RPC-first with LCD fallback
13
+ - **cosmjs-setup.js**: All 28 query bodies replaced with thin wrappers delegating to RPC-first modules
14
+ - **session-manager.js**: `buildSessionMap()` now uses RPC-first `querySessions()`
15
+ - **batch.js**: `waitForBatchSessions()` now uses RPC-first `querySessions()`
16
+ - **defaults.js**: Added runtime endpoint management — `addRpcEndpoint`, `removeRpcEndpoint`, `setEndpoints`, `getEndpoints`, `checkRpcEndpointHealth`, `optimizeEndpoints`
17
+ - **index.js**: 16 RPC query exports + 8 endpoint management exports
18
+ - **SDK_VERSION**: Bumped to 2.3.0
19
+
20
+ ### C# SDK Changes
21
+ - **RpcClient.cs**: Wired into ChainClient. 17 typed query methods (sessions, subscriptions, nodes, balance, provider, fee grants, authz, allocations)
22
+ - **ProtobufReader.cs**: Added `DecodeSession`, `DecodeSubscription`, `DecodeProvider` decoders
23
+ - **ChainClient.Queries.cs**: 13 methods upgraded to RPC-first with LCD fallback
24
+ - **ChainClient.FeeGrants.cs**: 2 methods upgraded to RPC-first with LCD fallback
25
+ - **Total**: 15 direct + 9 transitive = 24 query methods are RPC-first
26
+
27
+ ### Coverage
28
+ | Module | RPC-First | Total |
29
+ |--------|-----------|-------|
30
+ | JS chain/queries.js | 22/22 | 100% |
31
+ | JS chain/fee-grants.js | 7/7 | 100% |
32
+ | JS session-manager.js | 1/1 | 100% |
33
+ | JS batch.js | 1/1 | 100% |
34
+ | C# ChainClient.Queries | 13/13 | 100% |
35
+ | C# ChainClient.FeeGrants | 2/2 | 100% |
36
+
37
+ ---
38
+
5
39
  ## Documentation Versions
6
40
 
7
41
  | Version | Date | Changes |
package/batch.js CHANGED
@@ -25,14 +25,13 @@
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,
32
- findExistingSession,
33
- lcdPaginatedSafe,
34
32
  MSG_TYPES,
35
33
  } from './cosmjs-setup.js';
34
+ import { findExistingSession, querySessions } from './chain/queries.js';
36
35
 
37
36
  // ─── Constants ───────────────────────────────────────────────────────────────
38
37
 
@@ -247,7 +246,7 @@ async function _processBatch(client, account, batch, gigabytes, denom, ctx) {
247
246
  export async function waitForBatchSessions(nodeAddrs, walletAddr, lcdUrl, options = {}) {
248
247
  const maxWaitMs = options.maxWaitMs ?? DEFAULT_POLL_TIMEOUT;
249
248
  const pollIntervalMs = options.pollIntervalMs ?? 2000;
250
- const baseLcd = lcdUrl || 'https://lcd.sentinel.co';
249
+ const baseLcd = lcdUrl || DEFAULT_LCD;
251
250
 
252
251
  if (nodeAddrs.length === 0) return { confirmed: [], pending: [] };
253
252
 
@@ -257,18 +256,15 @@ export async function waitForBatchSessions(nodeAddrs, walletAddr, lcdUrl, option
257
256
  while (pending.size > 0 && Date.now() < deadline) {
258
257
  await sleep(pollIntervalMs);
259
258
  try {
260
- const result = await lcdPaginatedSafe(
261
- baseLcd,
262
- `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`,
263
- 'sessions',
264
- );
259
+ // RPC-first via chain/queries.js — returns flattened sessions
260
+ const result = await querySessions(walletAddr, baseLcd, { status: '1' });
265
261
  for (const s of (result.items || [])) {
266
262
  const bs = s.base_session || s;
267
263
  const n = bs.node_address || bs.node;
268
264
  if (pending.has(n)) pending.delete(n);
269
265
  }
270
266
  } catch {
271
- // Transient LCD error — will retry on next poll
267
+ // Transient RPC/LCD error — will retry on next poll
272
268
  }
273
269
  }
274
270
 
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,9 +13,15 @@ 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
+ import {
20
+ createRpcQueryClientWithFallback,
21
+ rpcQueryFeeGrant as _rpcQueryFeeGrant,
22
+ rpcQueryFeeGrants as _rpcQueryFeeGrants,
23
+ rpcQueryFeeGrantsIssued as _rpcQueryFeeGrantsIssued,
24
+ } from './rpc.js';
19
25
 
20
26
  // ─── Protobuf Helpers for FeeGrant ──────────────────────────────────────────
21
27
  // Uses the same manual protobuf encoding as Sentinel types — no codegen needed.
@@ -60,6 +66,21 @@ function encodeAny(typeUrl, valueBytes) {
60
66
  ]);
61
67
  }
62
68
 
69
+ // ─── RPC Client Helper ─────────────────────────────────────────────────────
70
+
71
+ let _rpcClient = null;
72
+ let _rpcClientPromise = null;
73
+
74
+ async function getRpcClient() {
75
+ if (_rpcClient) return _rpcClient;
76
+ if (_rpcClientPromise) return _rpcClientPromise;
77
+ _rpcClientPromise = createRpcQueryClientWithFallback()
78
+ .then(client => { _rpcClient = client; return client; })
79
+ .catch(() => { _rpcClient = null; return null; })
80
+ .finally(() => { _rpcClientPromise = null; });
81
+ return _rpcClientPromise;
82
+ }
83
+
63
84
  // ─── FeeGrant (cosmos.feegrant.v1beta1) ─────────────────────────────────────
64
85
 
65
86
  /**
@@ -105,31 +126,61 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
105
126
 
106
127
  /**
107
128
  * Query fee grants given to a grantee.
129
+ * RPC-first with LCD fallback.
108
130
  * @returns {Promise<Array>} Array of allowance objects
109
131
  */
110
132
  export async function queryFeeGrants(lcdUrl, grantee) {
133
+ // RPC-first
134
+ try {
135
+ const rpc = await getRpcClient();
136
+ if (rpc) {
137
+ return await _rpcQueryFeeGrants(rpc, grantee);
138
+ }
139
+ } catch { /* fall through to LCD */ }
140
+
141
+ // LCD fallback
111
142
  const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`, 'allowances');
112
143
  return items;
113
144
  }
114
145
 
115
146
  /**
116
147
  * Query fee grants issued BY an address (where addr is the granter).
148
+ * RPC-first with LCD fallback.
117
149
  * @param {string} lcdUrl
118
150
  * @param {string} granter - Address that issued the grants
119
151
  * @returns {Promise<Array>}
120
152
  */
121
153
  export async function queryFeeGrantsIssued(lcdUrl, granter) {
154
+ // RPC-first
155
+ try {
156
+ const rpc = await getRpcClient();
157
+ if (rpc) {
158
+ return await _rpcQueryFeeGrantsIssued(rpc, granter);
159
+ }
160
+ } catch { /* fall through to LCD */ }
161
+
162
+ // LCD fallback
122
163
  const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/issued/${granter}`, 'allowances');
123
164
  return items;
124
165
  }
125
166
 
126
167
  /**
127
168
  * Query a specific fee grant between granter and grantee.
169
+ * RPC-first with LCD fallback.
128
170
  * @returns {Promise<object|null>} Allowance object or null
129
171
  */
130
172
  export async function queryFeeGrant(lcdUrl, granter, grantee) {
173
+ // RPC-first
174
+ try {
175
+ const rpc = await getRpcClient();
176
+ if (rpc) {
177
+ return await _rpcQueryFeeGrant(rpc, granter, grantee);
178
+ }
179
+ } catch { /* fall through to LCD */ }
180
+
181
+ // LCD fallback (with endpoint failover via lcdQuery)
131
182
  try {
132
- const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
183
+ const data = await lcdQuery(`/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`, { lcdUrl });
133
184
  return data.allowance || null;
134
185
  } catch { return null; } // 404 = no grant
135
186
  }
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,10 @@ 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.
348
- *
349
- * Note: Sessions have a nested base_session object containing the actual data.
361
+ * Delegates to chain/queries.js RPC-first implementation.
350
362
  */
351
363
  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;
364
+ return _rpcFindExistingSession(lcdUrl, walletAddr, nodeAddr);
361
365
  }
362
366
 
363
367
  /**
@@ -377,176 +381,44 @@ export function resolveNodeUrl(node) {
377
381
  }
378
382
 
379
383
  /**
380
- * Fetch all active nodes from LCD with pagination.
384
+ * Fetch all active nodes with RPC-first + LCD fallback.
381
385
  * Returns array of node objects. Each node has `remote_url` resolved from `remote_addrs`.
386
+ * Delegates to chain/queries.js RPC-first implementation.
382
387
  */
383
388
  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;
389
+ return _rpcFetchActiveNodes(lcdUrl, limit, maxPages);
399
390
  }
400
391
 
401
392
  /**
402
393
  * 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.
394
+ * Delegates to chain/queries.js RPC-first implementation.
404
395
  *
405
396
  * @param {string} [lcdUrl] - LCD endpoint (default: cascading fallback)
406
397
  * @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
398
  */
413
399
  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
- };
400
+ return _rpcGetNetworkOverview(lcdUrl);
464
401
  }
465
402
 
466
403
  /**
467
404
  * Discover plan IDs by probing subscription endpoints.
468
- * Workaround for /sentinel/plan/v3/plans returning 501 Not Implemented.
405
+ * Delegates to chain/queries.js RPC-first implementation.
469
406
  * Returns sorted array of plan IDs that have at least 1 subscription.
470
407
  */
471
408
  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);
409
+ return _rpcDiscoverPlanIds(lcdUrl, maxId);
486
410
  }
487
411
 
488
412
  /**
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.
413
+ * Get standardized prices for a node — abstracts V3 price parsing entirely.
414
+ * Delegates to chain/queries.js RPC-first implementation (direct node lookup, not full-list scan).
493
415
  *
494
416
  * @param {string} nodeAddress - sentnode1... address
495
417
  * @param {string} [lcdUrl] - LCD endpoint URL (default: cascading fallback across all endpoints)
496
418
  * @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
419
  */
504
420
  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
- };
421
+ return _rpcGetNodePrices(nodeAddress, lcdUrl);
550
422
  }
551
423
 
552
424
  // ─── Display & Serialization Helpers ────────────────────────────────────────
@@ -761,22 +633,20 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
761
633
 
762
634
  /**
763
635
  * Query fee grants given to a grantee.
636
+ * Delegates to chain/fee-grants.js RPC-first implementation.
764
637
  * @returns {Promise<Array>} Array of allowance objects
765
638
  */
766
639
  export async function queryFeeGrants(lcdUrl, grantee) {
767
- const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`);
768
- return data.allowances || [];
640
+ return _rpcQueryFeeGrants(lcdUrl, grantee);
769
641
  }
770
642
 
771
643
  /**
772
644
  * Query a specific fee grant between granter and grantee.
645
+ * Delegates to chain/fee-grants.js RPC-first implementation.
773
646
  * @returns {Promise<object|null>} Allowance object or null
774
647
  */
775
648
  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
649
+ return _rpcQueryFeeGrant(lcdUrl, granter, grantee);
780
650
  }
781
651
 
782
652
  /**
@@ -878,14 +748,7 @@ export function encodeForExec(msgs) {
878
748
  });
879
749
  }
880
750
 
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
- }
751
+ // queryAuthzGrants removed — use RPC-first version from chain/queries.js
889
752
 
890
753
  // Re-export extractSessionId for convenience (from protocol module)
891
754
  export { extractSessionId };
package/chain/queries.js CHANGED
@@ -30,6 +30,8 @@ import {
30
30
  rpcQuerySubscriptionAllocations as rpcQuerySubAllocations,
31
31
  rpcQueryPlan,
32
32
  rpcQueryBalance,
33
+ rpcQueryProvider as _rpcQueryProvider,
34
+ rpcQueryAuthzGrants as _rpcQueryAuthzGrants,
33
35
  } from './rpc.js';
34
36
 
35
37
  // Re-export for convenience
@@ -54,6 +56,15 @@ async function getRpcClient() {
54
56
  return _rpcClientPromise;
55
57
  }
56
58
 
59
+ /**
60
+ * Clear the cached RPC query client. Called during process cleanup
61
+ * to ensure WebSocket connections are properly closed.
62
+ */
63
+ export function resetQueryRpcCache() {
64
+ _rpcClient = null;
65
+ _rpcClientPromise = null;
66
+ }
67
+
57
68
  // ─── Query Helpers ───────────────────────────────────────────────────────────
58
69
 
59
70
  /**
@@ -342,9 +353,9 @@ export async function querySessionById(lcdUrl, sessionId) {
342
353
  }
343
354
  } catch { /* fall through to LCD */ }
344
355
 
345
- // LCD fallback
356
+ // LCD fallback (with endpoint failover via lcdQuery)
346
357
  try {
347
- const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
358
+ const data = await lcdQuery(`/sentinel/session/v3/sessions/${sessionId}`, { lcdUrl });
348
359
  const raw = data?.session;
349
360
  if (!raw) return null;
350
361
  return flattenSession(raw);
@@ -370,9 +381,9 @@ export async function querySessionAllocation(lcdUrl, sessionId) {
370
381
  } catch { /* fall through */ }
371
382
 
372
383
  if (!s) {
373
- // LCD fallback
384
+ // LCD fallback (with endpoint failover via lcdQuery)
374
385
  try {
375
- const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
386
+ const data = await lcdQuery(`/sentinel/session/v3/sessions/${sessionId}`, { lcdUrl });
376
387
  s = data.session?.base_session || data.session || null;
377
388
  } catch { return null; }
378
389
  }
@@ -674,7 +685,7 @@ export async function queryPlanNodes(planId, lcdUrl) {
674
685
 
675
686
  /**
676
687
  * Discover all available plans with metadata (subscriber count, node count, price).
677
- * Probes plan IDs 1-maxId, returns plans with >=1 subscriber.
688
+ * RPC-first: probes plan IDs via rpcQueryPlan, falls back to LCD.
678
689
  *
679
690
  * @param {string} [lcdUrl]
680
691
  * @param {object} [opts]
@@ -690,19 +701,61 @@ export async function discoverPlans(lcdUrl, opts = {}) {
690
701
  const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
691
702
  const plans = [];
692
703
 
704
+ // Try to get RPC client for plan queries
705
+ let rpc = null;
706
+ try { rpc = await getRpcClient(); } catch { /* LCD only */ }
707
+
693
708
  for (let batch = 0; batch < Math.ceil(maxId / batchSize); batch++) {
694
709
  const probes = [];
695
710
  for (let i = batch * batchSize + 1; i <= Math.min((batch + 1) * batchSize, maxId); i++) {
696
711
  probes.push((async (id) => {
697
712
  try {
698
- const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
699
- const subCount = parseInt(subData.pagination?.total || '0', 10);
713
+ // RPC-first: query plan existence via RPC
714
+ let plan = null;
715
+ if (rpc) {
716
+ try { plan = await rpcQueryPlan(rpc, id); } catch { /* fall through */ }
717
+ }
718
+
719
+ // Get subscriber count — RPC for plan subs, LCD as fallback
720
+ let subCount = 0;
721
+ let price = null;
722
+ if (rpc) {
723
+ try {
724
+ const subs = await rpcQuerySubscriptionsForPlan(rpc, id, { limit: 1 });
725
+ subCount = subs.length; // Quick check — at least 1
726
+ price = subs[0]?.price || null;
727
+ // If RPC returned subs, plan exists even if rpcQueryPlan returned null
728
+ if (subCount > 0 && !plan) plan = { id };
729
+ } catch { /* fall through */ }
730
+ }
731
+ if (!plan && subCount === 0) {
732
+ // LCD fallback for subscriber count
733
+ try {
734
+ const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
735
+ subCount = parseInt(subData.pagination?.total || '0', 10);
736
+ price = subData.subscriptions?.[0]?.price || null;
737
+ if (subCount > 0) plan = { id };
738
+ } catch { /* plan doesn't exist */ }
739
+ }
740
+
741
+ if (!plan && !includeEmpty) return null;
700
742
  if (subCount === 0 && !includeEmpty) return null;
701
- // Plan nodes endpoint has broken pagination (count_total wrong, next_key null).
702
- // Use limit=5000 single request and count the actual array.
703
- const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
704
- const nodeCount = (nodeData.nodes || []).length;
705
- const price = subData.subscriptions?.[0]?.price || null;
743
+
744
+ // Get node count RPC-first
745
+ let nodeCount = 0;
746
+ if (rpc) {
747
+ try {
748
+ const nodes = await rpcQueryNodesForPlan(rpc, id, { status: 1, limit: 5000 });
749
+ nodeCount = nodes.length;
750
+ } catch { /* fall through to LCD */ }
751
+ }
752
+ if (nodeCount === 0) {
753
+ try {
754
+ const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
755
+ nodeCount = (nodeData.nodes || []).length;
756
+ } catch { /* no nodes */ }
757
+ }
758
+
706
759
  return { id, subscribers: subCount, nodeCount, price, hasNodes: nodeCount > 0 };
707
760
  } catch { return null; }
708
761
  })(i));
@@ -716,18 +769,51 @@ export async function discoverPlans(lcdUrl, opts = {}) {
716
769
 
717
770
  /**
718
771
  * Get provider details by address.
772
+ * RPC-first with LCD fallback. Provider is still v2 on chain.
719
773
  * @param {string} provAddress - sentprov1... address
720
774
  * @param {object} [opts]
721
775
  * @param {string} [opts.lcdUrl]
722
776
  * @returns {Promise<object|null>}
723
777
  */
724
778
  export async function getProviderByAddress(provAddress, opts = {}) {
779
+ // RPC-first
780
+ try {
781
+ const rpc = await getRpcClient();
782
+ if (rpc) {
783
+ const provider = await _rpcQueryProvider(rpc, provAddress);
784
+ if (provider) return provider;
785
+ }
786
+ } catch { /* fall through to LCD */ }
787
+
788
+ // LCD fallback
725
789
  try {
726
790
  const data = await lcdQuery(`/sentinel/provider/v2/providers/${provAddress}`, opts);
727
791
  return data.provider || null;
728
792
  } catch { return null; }
729
793
  }
730
794
 
795
+ /**
796
+ * Query authz grants between granter and grantee.
797
+ * RPC-first with LCD fallback.
798
+ * @param {string} lcdUrl - LCD endpoint
799
+ * @param {string} granter - Granter address (sent1...)
800
+ * @param {string} grantee - Grantee address (sent1...)
801
+ * @returns {Promise<Array>}
802
+ */
803
+ export async function queryAuthzGrants(lcdUrl, granter, grantee) {
804
+ // RPC-first
805
+ try {
806
+ const rpc = await getRpcClient();
807
+ if (rpc) {
808
+ return await _rpcQueryAuthzGrants(rpc, granter, grantee);
809
+ }
810
+ } catch { /* fall through to LCD */ }
811
+
812
+ // LCD fallback
813
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`, 'grants');
814
+ return items;
815
+ }
816
+
731
817
  // ─── VPN Settings Persistence ────────────────────────────────────────────────
732
818
  // v27: Persistent user settings (backported from C# VpnSettings.cs).
733
819
  // Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.