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/chain/queries.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Sentinel SDK — Chain / Queries Module
3
3
  *
4
- * All LCD-based query functions: balance, nodes, sessions, subscriptions,
5
- * plans, pricing, discovery, and display/serialization helpers.
4
+ * RPC-first query functions with LCD fallback.
5
+ * All queries try RPC (protobuf via CosmJS ABCI) first for speed (~912x faster),
6
+ * then fall back to LCD (REST) if RPC fails.
6
7
  *
7
8
  * Usage:
8
9
  * import { getBalance, fetchActiveNodes, queryNode } from './chain/queries.js';
@@ -16,10 +17,45 @@ import { LCD_ENDPOINTS, tryWithFallback } from '../defaults.js';
16
17
  import { ValidationError, NodeError, ChainError, ErrorCodes } from '../errors.js';
17
18
  import { extractSessionId } from '../v3protocol.js';
18
19
  import { lcd, lcdQuery, lcdQueryAll, lcdPaginatedSafe } from './lcd.js';
20
+ import {
21
+ createRpcQueryClientWithFallback,
22
+ rpcQueryNodes,
23
+ rpcQueryNode,
24
+ rpcQueryNodesForPlan,
25
+ rpcQuerySession,
26
+ rpcQuerySessionsForAccount,
27
+ rpcQuerySubscription,
28
+ rpcQuerySubscriptionsForAccount,
29
+ rpcQuerySubscriptionsForPlan,
30
+ rpcQuerySubscriptionAllocations as rpcQuerySubAllocations,
31
+ rpcQueryPlan,
32
+ rpcQueryBalance,
33
+ rpcQueryProvider as _rpcQueryProvider,
34
+ rpcQueryAuthzGrants as _rpcQueryAuthzGrants,
35
+ } from './rpc.js';
19
36
 
20
37
  // Re-export for convenience
21
38
  export { extractSessionId };
22
39
 
40
+ // ─── RPC Client Helper ─────────────────────────────────────────────────────
41
+
42
+ let _rpcClient = null;
43
+ let _rpcClientPromise = null;
44
+
45
+ /**
46
+ * Get or create a cached RPC query client. Returns null if all RPC endpoints fail
47
+ * (caller should fall back to LCD).
48
+ */
49
+ async function getRpcClient() {
50
+ if (_rpcClient) return _rpcClient;
51
+ if (_rpcClientPromise) return _rpcClientPromise;
52
+ _rpcClientPromise = createRpcQueryClientWithFallback()
53
+ .then(client => { _rpcClient = client; return client; })
54
+ .catch(() => { _rpcClient = null; return null; })
55
+ .finally(() => { _rpcClientPromise = null; });
56
+ return _rpcClientPromise;
57
+ }
58
+
23
59
  // ─── Query Helpers ───────────────────────────────────────────────────────────
24
60
 
25
61
  /**
@@ -35,20 +71,38 @@ export async function getBalance(client, address) {
35
71
  /**
36
72
  * Find an existing active session for a wallet+node pair.
37
73
  * Returns session ID (BigInt) or null. Use this to avoid double-paying.
38
- *
39
- * Note: Sessions have a nested base_session object containing the actual data.
74
+ * RPC-first with LCD fallback.
40
75
  */
41
76
  export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
42
- const { items } = await lcdPaginatedSafe(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`, 'sessions');
43
- for (const s of items) {
44
- const bs = s.base_session || s;
45
- if ((bs.node_address || bs.node) !== nodeAddr) continue;
46
- if (bs.status && bs.status !== 'active') continue;
47
- const acct = bs.acc_address || bs.address;
77
+ let sessions;
78
+
79
+ // RPC-first: returns decoded, flat session objects
80
+ try {
81
+ const rpc = await getRpcClient();
82
+ if (rpc) {
83
+ sessions = await rpcQuerySessionsForAccount(rpc, walletAddr, { limit: 500 });
84
+ }
85
+ } catch { /* RPC failed, fall through to LCD */ }
86
+
87
+ if (!sessions) {
88
+ // LCD fallback
89
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`, 'sessions');
90
+ sessions = items.map(s => {
91
+ const bs = s.base_session || s;
92
+ return { ...bs, price: s.price, subscription_id: s.subscription_id };
93
+ });
94
+ }
95
+
96
+ for (const s of sessions) {
97
+ if ((s.node_address || s.node) !== nodeAddr) continue;
98
+ // RPC returns status as number (1=active), LCD as string
99
+ const st = s.status;
100
+ if (st && st !== 1 && st !== 'active') continue;
101
+ const acct = s.acc_address || s.address;
48
102
  if (acct && acct !== walletAddr) continue;
49
- const maxBytes = parseInt(bs.max_bytes || '0');
50
- const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
51
- if (maxBytes === 0 || used < maxBytes) return BigInt(bs.id);
103
+ const maxBytes = parseInt(s.max_bytes || '0');
104
+ const used = parseInt(s.download_bytes || '0') + parseInt(s.upload_bytes || '0');
105
+ if (maxBytes === 0 || used < maxBytes) return BigInt(s.id);
52
106
  }
53
107
  return null;
54
108
  }
@@ -80,7 +134,7 @@ const NODE_CACHE_TTL = 5 * 60_000; // 5 minutes
80
134
  export function invalidateNodeCache() { _nodeListCache = null; }
81
135
 
82
136
  /**
83
- * Fetch all active nodes from LCD with pagination.
137
+ * Fetch all active nodes via RPC (primary) with LCD fallback.
84
138
  * Returns array of node objects. Each node has:
85
139
  * - `remote_url`: the first usable HTTPS URL (for primary connection)
86
140
  * - `remoteAddrs`: ALL remote addresses (for fallback on connection failure)
@@ -93,7 +147,21 @@ export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
93
147
  return _nodeListCache.map(n => ({ ...n, planIds: [...(n.planIds || [])] }));
94
148
  }
95
149
 
96
- const { items } = await lcdPaginatedSafe(lcdUrl, '/sentinel/node/v3/nodes?status=1', 'nodes', { limit });
150
+ let items;
151
+ try {
152
+ // RPC-first: ~912x faster than LCD for bulk node queries
153
+ const rpc = await getRpcClient();
154
+ if (rpc) {
155
+ items = await rpcQueryNodes(rpc, { status: 1, limit });
156
+ }
157
+ } catch { /* RPC failed, fall through to LCD */ }
158
+
159
+ if (!items) {
160
+ // LCD fallback
161
+ const result = await lcdPaginatedSafe(lcdUrl, '/sentinel/node/v3/nodes?status=1', 'nodes', { limit });
162
+ items = result.items;
163
+ }
164
+
97
165
  for (const n of items) {
98
166
  // Preserve ALL remote addresses for fallback
99
167
  const addrs = n.remote_addrs || [];
@@ -228,21 +296,35 @@ export async function getNodePrices(nodeAddress, lcdUrl) {
228
296
 
229
297
  /**
230
298
  * Query a wallet's active subscriptions.
299
+ * RPC-first with LCD fallback.
231
300
  * @param {string} lcdUrl
232
301
  * @param {string} walletAddr - sent1... address
233
- * @returns {Promise<{ subscriptions: any[], total: number|null }>}
302
+ * @returns {Promise<{ items: any[], total: number|null }>}
234
303
  */
235
304
  export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
236
- // v26: Correct LCD endpoint for wallet subscriptions
237
- let path = `/sentinel/subscription/v3/accounts/${walletAddr}/subscriptions`;
238
- if (opts.status) path += `?status=${opts.status === 'active' ? '1' : '2'}`;
239
- return lcdQueryAll(path, { lcdUrl, dataKey: 'subscriptions' });
305
+ // RPC-first
306
+ try {
307
+ const rpc = await getRpcClient();
308
+ if (rpc) {
309
+ let subs = await rpcQuerySubscriptionsForAccount(rpc, walletAddr, { limit: 500 });
310
+ if (opts.status) {
311
+ const statusNum = opts.status === 'active' ? 1 : 2;
312
+ subs = subs.filter(s => s.status === statusNum);
313
+ }
314
+ return { items: subs, total: subs.length };
315
+ }
316
+ } catch { /* fall through to LCD */ }
317
+
318
+ // LCD fallback
319
+ let lcdPath = `/sentinel/subscription/v3/accounts/${walletAddr}/subscriptions`;
320
+ if (opts.status) lcdPath += `?status=${opts.status === 'active' ? '1' : '2'}`;
321
+ return lcdQueryAll(lcdPath, { lcdUrl, dataKey: 'subscriptions' });
240
322
  }
241
323
 
242
324
  /**
243
325
  * Query a single session directly by ID — O(1) instead of scanning all wallet sessions.
244
326
  * Returns the flattened session object or null if not found.
245
- * Use this when you know the session ID (e.g., from batch TX events).
327
+ * RPC-first with LCD fallback.
246
328
  *
247
329
  * @param {string} lcdUrl - LCD endpoint URL
248
330
  * @param {string|number|bigint} sessionId - Session ID to query
@@ -253,6 +335,16 @@ export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
253
335
  * if (session) console.log(`Session ${session.id} on node ${session.node_address}`);
254
336
  */
255
337
  export async function querySessionById(lcdUrl, sessionId) {
338
+ // RPC-first
339
+ try {
340
+ const rpc = await getRpcClient();
341
+ if (rpc) {
342
+ const session = await rpcQuerySession(rpc, sessionId);
343
+ if (session) return session;
344
+ }
345
+ } catch { /* fall through to LCD */ }
346
+
347
+ // LCD fallback
256
348
  try {
257
349
  const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
258
350
  const raw = data?.session;
@@ -263,30 +355,45 @@ export async function querySessionById(lcdUrl, sessionId) {
263
355
 
264
356
  /**
265
357
  * Query session allocation (remaining bandwidth).
358
+ * RPC-first with LCD fallback.
266
359
  * @param {string} lcdUrl
267
360
  * @param {string|number|bigint} sessionId
268
361
  * @returns {Promise<{ maxBytes: number, usedBytes: number, remainingBytes: number, percentUsed: number }|null>}
269
362
  */
270
363
  export async function querySessionAllocation(lcdUrl, sessionId) {
364
+ let s = null;
365
+
366
+ // RPC-first: query session by ID returns flat decoded object
271
367
  try {
272
- const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
273
- const s = data.session?.base_session || data.session || {};
274
- const maxBytes = parseInt(s.max_bytes || '0', 10);
275
- const dl = parseInt(s.download_bytes || '0', 10);
276
- const ul = parseInt(s.upload_bytes || '0', 10);
277
- const usedBytes = dl + ul;
278
- return {
279
- maxBytes,
280
- usedBytes,
281
- remainingBytes: Math.max(0, maxBytes - usedBytes),
282
- percentUsed: maxBytes > 0 ? Math.round((usedBytes / maxBytes) * 100) : 0,
283
- };
284
- } catch { return null; }
368
+ const rpc = await getRpcClient();
369
+ if (rpc) {
370
+ s = await rpcQuerySession(rpc, sessionId);
371
+ }
372
+ } catch { /* fall through */ }
373
+
374
+ if (!s) {
375
+ // LCD fallback
376
+ try {
377
+ const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
378
+ s = data.session?.base_session || data.session || null;
379
+ } catch { return null; }
380
+ }
381
+
382
+ if (!s) return null;
383
+ const maxBytes = parseInt(s.max_bytes || '0', 10);
384
+ const dl = parseInt(s.download_bytes || '0', 10);
385
+ const ul = parseInt(s.upload_bytes || '0', 10);
386
+ const usedBytes = dl + ul;
387
+ return {
388
+ maxBytes,
389
+ usedBytes,
390
+ remainingBytes: Math.max(0, maxBytes - usedBytes),
391
+ percentUsed: maxBytes > 0 ? Math.round((usedBytes / maxBytes) * 100) : 0,
392
+ };
285
393
  }
286
394
 
287
395
  /**
288
- * Fetch a single node by address from LCD (no need to fetch all 1000+ nodes).
289
- * Tries the direct v3 endpoint first, falls back to paginated search.
396
+ * Fetch a single node by address via RPC (primary) with LCD fallback.
290
397
  *
291
398
  * @param {string} nodeAddress - sentnode1... address
292
399
  * @param {object} [opts]
@@ -298,6 +405,21 @@ export async function queryNode(nodeAddress, opts = {}) {
298
405
  throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be sentnode1...', { nodeAddress });
299
406
  }
300
407
 
408
+ // RPC-first: single node query is fast and avoids LCD rate limits
409
+ try {
410
+ const rpc = await getRpcClient();
411
+ if (rpc) {
412
+ const node = await rpcQueryNode(rpc, nodeAddress);
413
+ if (node) {
414
+ node.remote_url = resolveNodeUrl(node);
415
+ const addrs = node.remote_addrs || [];
416
+ node.remoteAddrs = addrs.map(a => a.startsWith('http') ? a : `https://${a}`);
417
+ return node;
418
+ }
419
+ }
420
+ } catch { /* RPC failed, fall through to LCD */ }
421
+
422
+ // LCD fallback
301
423
  const fetchDirect = async (baseUrl) => {
302
424
  try {
303
425
  const data = await lcdQuery(`/sentinel/node/v3/nodes/${nodeAddress}`, { lcdUrl: baseUrl });
@@ -308,18 +430,19 @@ export async function queryNode(nodeAddress, opts = {}) {
308
430
  } catch { /* fall through to full list */ }
309
431
  const { items } = await lcdPaginatedSafe(baseUrl, '/sentinel/node/v3/nodes?status=1', 'nodes');
310
432
  const found = items.find(n => n.address === nodeAddress);
311
- if (!found) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found on LCD (may be inactive)`, { nodeAddress });
433
+ if (!found) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found (may be inactive)`, { nodeAddress });
312
434
  found.remote_url = resolveNodeUrl(found);
313
435
  return found;
314
436
  };
315
437
 
316
438
  if (opts.lcdUrl) return fetchDirect(opts.lcdUrl);
317
- const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `LCD node lookup ${nodeAddress}`);
439
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `node lookup ${nodeAddress}`);
318
440
  return result;
319
441
  }
320
442
 
321
443
  /**
322
444
  * List all sessions for a wallet address.
445
+ * RPC-first with LCD fallback.
323
446
  * @param {string} address - sent1... wallet address
324
447
  * @param {string} [lcdUrl]
325
448
  * @param {object} [opts]
@@ -327,10 +450,25 @@ export async function queryNode(nodeAddress, opts = {}) {
327
450
  * @returns {Promise<{ items: ChainSession[], total: number }>}
328
451
  */
329
452
  export async function querySessions(address, lcdUrl, opts = {}) {
330
- let path = `/sentinel/session/v3/sessions?address=${address}`;
331
- if (opts.status) path += `&status=${opts.status}`;
332
- const result = await lcdPaginatedSafe(lcdUrl, path, 'sessions');
333
- // Auto-flatten base_session nesting so devs don't hit session.id === undefined
453
+ // RPC-first: returns already-flat decoded sessions
454
+ try {
455
+ const rpc = await getRpcClient();
456
+ if (rpc) {
457
+ const sessions = await rpcQuerySessionsForAccount(rpc, address, { limit: 500 });
458
+ // Filter by status if requested (RPC returns all statuses)
459
+ let items = sessions;
460
+ if (opts.status) {
461
+ const statusNum = parseInt(opts.status, 10);
462
+ items = sessions.filter(s => s.status === statusNum);
463
+ }
464
+ return { items, total: items.length };
465
+ }
466
+ } catch { /* fall through to LCD */ }
467
+
468
+ // LCD fallback
469
+ let lcdPath = `/sentinel/session/v3/sessions?address=${address}`;
470
+ if (opts.status) lcdPath += `&status=${opts.status}`;
471
+ const result = await lcdPaginatedSafe(lcdUrl, lcdPath, 'sessions');
334
472
  result.items = result.items.map(flattenSession);
335
473
  return result;
336
474
  }
@@ -369,11 +507,22 @@ export function flattenSession(session) {
369
507
 
370
508
  /**
371
509
  * Get a single subscription by ID.
510
+ * RPC-first with LCD fallback.
372
511
  * @param {string|number} id - Subscription ID
373
512
  * @param {string} [lcdUrl]
374
513
  * @returns {Promise<Subscription|null>}
375
514
  */
376
515
  export async function querySubscription(id, lcdUrl) {
516
+ // RPC-first
517
+ try {
518
+ const rpc = await getRpcClient();
519
+ if (rpc) {
520
+ const sub = await rpcQuerySubscription(rpc, id);
521
+ if (sub) return sub;
522
+ }
523
+ } catch { /* fall through */ }
524
+
525
+ // LCD fallback
377
526
  try {
378
527
  const data = await lcdQuery(`/sentinel/subscription/v3/subscriptions/${id}`, { lcdUrl });
379
528
  return data.subscription || null;
@@ -382,6 +531,7 @@ export async function querySubscription(id, lcdUrl) {
382
531
 
383
532
  /**
384
533
  * Check if wallet has an active subscription for a specific plan.
534
+ * Uses querySubscriptions which is already RPC-first.
385
535
  * @param {string} address - sent1... wallet address
386
536
  * @param {number|string} planId - Plan ID to check
387
537
  * @param {string} [lcdUrl]
@@ -396,14 +546,28 @@ export async function hasActiveSubscription(address, planId, lcdUrl) {
396
546
 
397
547
  /**
398
548
  * Query allocations for a subscription (who has how many bytes).
399
- * NOTE: Uses v2 endpoint because this specific v3 query hasn't been implemented yet
400
- * (returns 501). The v2 path returns the same allocation data. Same situation as /plan/v3/plans/{id}.
549
+ * RPC-first with LCD fallback. Uses v2 allocation path (v3 returns 501 on chain).
401
550
  *
402
551
  * @param {string|number|bigint} subscriptionId
403
552
  * @param {string} [lcdUrl]
404
553
  * @returns {Promise<Array<{ id: string, address: string, grantedBytes: string, utilisedBytes: string }>>}
405
554
  */
406
555
  export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
556
+ // RPC-first
557
+ try {
558
+ const rpc = await getRpcClient();
559
+ if (rpc) {
560
+ const allocs = await rpcQuerySubAllocations(rpc, subscriptionId, { limit: 100 });
561
+ return allocs.map(a => ({
562
+ id: a.id,
563
+ address: a.address,
564
+ grantedBytes: a.granted_bytes || '0',
565
+ utilisedBytes: a.utilised_bytes || '0',
566
+ }));
567
+ }
568
+ } catch { /* fall through */ }
569
+
570
+ // LCD fallback
407
571
  try {
408
572
  const data = await lcdQuery(`/sentinel/subscription/v2/subscriptions/${subscriptionId}/allocations`, { lcdUrl });
409
573
  return (data.allocations || []).map(a => ({
@@ -419,6 +583,7 @@ export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
419
583
 
420
584
  /**
421
585
  * Query all subscriptions for a plan. Supports owner filtering.
586
+ * RPC-first with LCD fallback.
422
587
  *
423
588
  * @param {number|string} planId - Plan ID
424
589
  * @param {object} [opts]
@@ -427,12 +592,30 @@ export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
427
592
  * @returns {Promise<{ subscribers: Array<{ address: string, status: number, id: string }>, total: number|null }>}
428
593
  */
429
594
  export async function queryPlanSubscribers(planId, opts = {}) {
430
- const { items, total } = await lcdQueryAll(
431
- `/sentinel/subscription/v3/plans/${planId}/subscriptions`,
432
- { lcdUrl: opts.lcdUrl, dataKey: 'subscriptions' },
433
- );
595
+ let items;
596
+ let total;
597
+
598
+ // RPC-first
599
+ try {
600
+ const rpc = await getRpcClient();
601
+ if (rpc) {
602
+ items = await rpcQuerySubscriptionsForPlan(rpc, planId, { limit: 500 });
603
+ total = items.length;
604
+ }
605
+ } catch { /* fall through */ }
606
+
607
+ if (!items) {
608
+ // LCD fallback
609
+ const result = await lcdQueryAll(
610
+ `/sentinel/subscription/v3/plans/${planId}/subscriptions`,
611
+ { lcdUrl: opts.lcdUrl, dataKey: 'subscriptions' },
612
+ );
613
+ items = result.items;
614
+ total = result.total;
615
+ }
616
+
434
617
  let subscribers = items.map(s => ({
435
- address: s.address || s.subscriber,
618
+ address: s.address || s.acc_address || s.subscriber,
436
619
  status: s.status,
437
620
  id: s.id || s.base_id,
438
621
  ...s,
@@ -466,26 +649,34 @@ export async function getPlanStats(planId, ownerAddress, opts = {}) {
466
649
  // ─── v26: Field Experience Helpers ────────────────────────────────────────────
467
650
 
468
651
  /**
469
- * Query all nodes linked to a plan.
652
+ * Query all nodes linked to a plan via RPC (primary) with LCD fallback.
470
653
  * @param {number|string} planId
471
654
  * @param {string} [lcdUrl]
472
655
  * @returns {Promise<{ items: any[], total: number|null }>}
473
656
  */
474
657
  export async function queryPlanNodes(planId, lcdUrl) {
475
- // LCD pagination is BROKEN on this endpoint — count_total returns min(actual, limit)
476
- // and next_key is always null. Single high-limit request gets all nodes.
658
+ // RPC-first
659
+ try {
660
+ const rpc = await getRpcClient();
661
+ if (rpc) {
662
+ const nodes = await rpcQueryNodesForPlan(rpc, planId, { status: 1, limit: 5000 });
663
+ return { items: nodes, total: nodes.length };
664
+ }
665
+ } catch { /* RPC failed, fall through to LCD */ }
666
+
667
+ // LCD fallback — pagination is BROKEN on this endpoint, single high-limit request
477
668
  const doQuery = async (baseUrl) => {
478
669
  const data = await lcd(baseUrl, `/sentinel/node/v3/plans/${planId}/nodes?pagination.limit=5000`);
479
670
  return { items: data.nodes || [], total: (data.nodes || []).length };
480
671
  };
481
672
  if (lcdUrl) return doQuery(lcdUrl);
482
- const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD plan ${planId} nodes`);
673
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `plan ${planId} nodes`);
483
674
  return result;
484
675
  }
485
676
 
486
677
  /**
487
678
  * Discover all available plans with metadata (subscriber count, node count, price).
488
- * Probes plan IDs 1-maxId, returns plans with >=1 subscriber.
679
+ * RPC-first: probes plan IDs via rpcQueryPlan, falls back to LCD.
489
680
  *
490
681
  * @param {string} [lcdUrl]
491
682
  * @param {object} [opts]
@@ -501,19 +692,61 @@ export async function discoverPlans(lcdUrl, opts = {}) {
501
692
  const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
502
693
  const plans = [];
503
694
 
695
+ // Try to get RPC client for plan queries
696
+ let rpc = null;
697
+ try { rpc = await getRpcClient(); } catch { /* LCD only */ }
698
+
504
699
  for (let batch = 0; batch < Math.ceil(maxId / batchSize); batch++) {
505
700
  const probes = [];
506
701
  for (let i = batch * batchSize + 1; i <= Math.min((batch + 1) * batchSize, maxId); i++) {
507
702
  probes.push((async (id) => {
508
703
  try {
509
- const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
510
- const subCount = parseInt(subData.pagination?.total || '0', 10);
704
+ // RPC-first: query plan existence via RPC
705
+ let plan = null;
706
+ if (rpc) {
707
+ try { plan = await rpcQueryPlan(rpc, id); } catch { /* fall through */ }
708
+ }
709
+
710
+ // Get subscriber count — RPC for plan subs, LCD as fallback
711
+ let subCount = 0;
712
+ let price = null;
713
+ if (rpc) {
714
+ try {
715
+ const subs = await rpcQuerySubscriptionsForPlan(rpc, id, { limit: 1 });
716
+ subCount = subs.length; // Quick check — at least 1
717
+ price = subs[0]?.price || null;
718
+ // If RPC returned subs, plan exists even if rpcQueryPlan returned null
719
+ if (subCount > 0 && !plan) plan = { id };
720
+ } catch { /* fall through */ }
721
+ }
722
+ if (!plan && subCount === 0) {
723
+ // LCD fallback for subscriber count
724
+ try {
725
+ const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
726
+ subCount = parseInt(subData.pagination?.total || '0', 10);
727
+ price = subData.subscriptions?.[0]?.price || null;
728
+ if (subCount > 0) plan = { id };
729
+ } catch { /* plan doesn't exist */ }
730
+ }
731
+
732
+ if (!plan && !includeEmpty) return null;
511
733
  if (subCount === 0 && !includeEmpty) return null;
512
- // Plan nodes endpoint has broken pagination (count_total wrong, next_key null).
513
- // Use limit=5000 single request and count the actual array.
514
- const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
515
- const nodeCount = (nodeData.nodes || []).length;
516
- const price = subData.subscriptions?.[0]?.price || null;
734
+
735
+ // Get node count RPC-first
736
+ let nodeCount = 0;
737
+ if (rpc) {
738
+ try {
739
+ const nodes = await rpcQueryNodesForPlan(rpc, id, { status: 1, limit: 5000 });
740
+ nodeCount = nodes.length;
741
+ } catch { /* fall through to LCD */ }
742
+ }
743
+ if (nodeCount === 0) {
744
+ try {
745
+ const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
746
+ nodeCount = (nodeData.nodes || []).length;
747
+ } catch { /* no nodes */ }
748
+ }
749
+
517
750
  return { id, subscribers: subCount, nodeCount, price, hasNodes: nodeCount > 0 };
518
751
  } catch { return null; }
519
752
  })(i));
@@ -527,18 +760,51 @@ export async function discoverPlans(lcdUrl, opts = {}) {
527
760
 
528
761
  /**
529
762
  * Get provider details by address.
763
+ * RPC-first with LCD fallback. Provider is still v2 on chain.
530
764
  * @param {string} provAddress - sentprov1... address
531
765
  * @param {object} [opts]
532
766
  * @param {string} [opts.lcdUrl]
533
767
  * @returns {Promise<object|null>}
534
768
  */
535
769
  export async function getProviderByAddress(provAddress, opts = {}) {
770
+ // RPC-first
771
+ try {
772
+ const rpc = await getRpcClient();
773
+ if (rpc) {
774
+ const provider = await _rpcQueryProvider(rpc, provAddress);
775
+ if (provider) return provider;
776
+ }
777
+ } catch { /* fall through to LCD */ }
778
+
779
+ // LCD fallback
536
780
  try {
537
781
  const data = await lcdQuery(`/sentinel/provider/v2/providers/${provAddress}`, opts);
538
782
  return data.provider || null;
539
783
  } catch { return null; }
540
784
  }
541
785
 
786
+ /**
787
+ * Query authz grants between granter and grantee.
788
+ * RPC-first with LCD fallback.
789
+ * @param {string} lcdUrl - LCD endpoint
790
+ * @param {string} granter - Granter address (sent1...)
791
+ * @param {string} grantee - Grantee address (sent1...)
792
+ * @returns {Promise<Array>}
793
+ */
794
+ export async function queryAuthzGrants(lcdUrl, granter, grantee) {
795
+ // RPC-first
796
+ try {
797
+ const rpc = await getRpcClient();
798
+ if (rpc) {
799
+ return await _rpcQueryAuthzGrants(rpc, granter, grantee);
800
+ }
801
+ } catch { /* fall through to LCD */ }
802
+
803
+ // LCD fallback
804
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`, 'grants');
805
+ return items;
806
+ }
807
+
542
808
  // ─── VPN Settings Persistence ────────────────────────────────────────────────
543
809
  // v27: Persistent user settings (backported from C# VpnSettings.cs).
544
810
  // Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.