blue-js-sdk 2.1.1 → 2.2.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.
@@ -34,9 +34,12 @@ import {
34
34
  getBalance as sdkGetBalance,
35
35
  tryWithFallback,
36
36
  RPC_ENDPOINTS,
37
+ LCD_ENDPOINTS,
38
+ queryFeeGrant,
37
39
  // v1.5.0: RPC queries (protobuf, ~10x faster than LCD for balance checks)
38
40
  createRpcQueryClientWithFallback,
39
41
  rpcQueryBalance,
42
+ rpcQueryFeeGrant,
40
43
  // v1.5.0: Typed event parsers (replaces string matching for session ID extraction)
41
44
  extractSessionIdTyped,
42
45
  NodeEventCreateSession,
@@ -193,6 +196,18 @@ function humanError(err) {
193
196
  message: 'Connection was cancelled.',
194
197
  nextAction: 'none',
195
198
  },
199
+ FEE_GRANT_NOT_FOUND: {
200
+ message: 'No fee grant from operator to agent. Operator must provision a grant first.',
201
+ nextAction: 'request_fee_grant',
202
+ },
203
+ FEE_GRANT_EXPIRED: {
204
+ message: 'Fee grant has expired. Operator must renew the grant.',
205
+ nextAction: 'request_fee_grant_renewal',
206
+ },
207
+ FEE_GRANT_EXHAUSTED: {
208
+ message: 'Fee grant spend limit exhausted. Operator must top up the grant.',
209
+ nextAction: 'request_fee_grant_renewal',
210
+ },
196
211
  };
197
212
 
198
213
  const entry = messages[code];
@@ -364,6 +379,101 @@ export async function connect(opts = {}) {
364
379
  }
365
380
  timings.balance = Date.now() - t0;
366
381
 
382
+ // ── STEP 3.5: Fee Grant Validity Check (when feeGranter is set) ───────────
383
+ // Verify the fee grant exists on-chain and hasn't expired before attempting
384
+ // a connection that would fail at broadcast time.
385
+
386
+ if (opts.feeGranter) {
387
+ try {
388
+ // RPC first (protobuf, ~10x faster), LCD fallback
389
+ let grant = null;
390
+ try {
391
+ const rpcClient = await createRpcQueryClientWithFallback();
392
+ grant = await rpcQueryFeeGrant(rpcClient, opts.feeGranter, walletAddress);
393
+ } catch {
394
+ // RPC failed — fall back to LCD with failover
395
+ const lcdResult = await tryWithFallback(
396
+ LCD_ENDPOINTS,
397
+ async (endpoint) => {
398
+ const url = endpoint?.url || endpoint;
399
+ return queryFeeGrant(url, opts.feeGranter, walletAddress);
400
+ },
401
+ 'fee grant pre-check (LCD fallback)',
402
+ );
403
+ grant = lcdResult.result;
404
+ }
405
+
406
+ if (!grant) {
407
+ const err = new Error(`No fee grant found from ${opts.feeGranter} to ${walletAddress}. Operator must create a fee grant before agent can connect with 0 P2P.`);
408
+ err.code = 'FEE_GRANT_NOT_FOUND';
409
+ err.nextAction = 'request_fee_grant';
410
+ err.details = { granter: opts.feeGranter, grantee: walletAddress };
411
+ throw err;
412
+ }
413
+
414
+ // Unwrap grant structure: AllowedMsgAllowance > BasicAllowance
415
+ // Chain returns: { allowance: { "@type": "AllowedMsg...", allowance: { "@type": "Basic...", spend_limit, expiration }, allowed_messages: [...] } }
416
+ const outerAllowance = grant.allowance || grant;
417
+ const isAllowedMsg = outerAllowance['@type']?.includes('AllowedMsgAllowance');
418
+ const inner = isAllowedMsg ? (outerAllowance.allowance || outerAllowance) : outerAllowance;
419
+ const expiration = inner.expiration || outerAllowance.expiration;
420
+
421
+ // Check expiration
422
+ if (expiration) {
423
+ const expiresAt = new Date(expiration);
424
+ const now = new Date();
425
+ if (expiresAt <= now) {
426
+ const err = new Error(`Fee grant from ${opts.feeGranter} expired at ${expiresAt.toISOString()}. Operator must renew the fee grant.`);
427
+ err.code = 'FEE_GRANT_EXPIRED';
428
+ err.nextAction = 'request_fee_grant_renewal';
429
+ err.details = { granter: opts.feeGranter, grantee: walletAddress, expiredAt: expiresAt.toISOString() };
430
+ throw err;
431
+ }
432
+
433
+ const hoursLeft = (expiresAt - now) / 3600000;
434
+ if (hoursLeft < 1) {
435
+ log(3, totalSteps, 'FEE_GRANT', `Warning: Fee grant expires in ${Math.round(hoursLeft * 60)} minutes`);
436
+ } else {
437
+ log(3, totalSteps, 'FEE_GRANT', `Fee grant valid, expires ${expiresAt.toISOString()} (${Math.round(hoursLeft)}h remaining)`);
438
+ }
439
+ } else {
440
+ log(3, totalSteps, 'FEE_GRANT', 'Fee grant valid (no expiration)');
441
+ }
442
+
443
+ // Check spend_limit — if set, ensure there's enough remaining for at least one TX
444
+ const spendLimit = inner.spend_limit;
445
+ if (spendLimit && Array.isArray(spendLimit)) {
446
+ const udvpnLimit = spendLimit.find(c => c.denom === 'udvpn');
447
+ if (udvpnLimit) {
448
+ const remaining = parseInt(udvpnLimit.amount, 10) || 0;
449
+ if (remaining < 20000) { // 20,000 udvpn = minimum for one session TX
450
+ const err = new Error(`Fee grant from ${opts.feeGranter} has insufficient spend limit: ${remaining} udvpn remaining (need 20,000 for session TX). Operator must top up the grant.`);
451
+ err.code = 'FEE_GRANT_EXHAUSTED';
452
+ err.nextAction = 'request_fee_grant_renewal';
453
+ err.details = { granter: opts.feeGranter, grantee: walletAddress, remainingUdvpn: remaining };
454
+ throw err;
455
+ }
456
+ log(3, totalSteps, 'FEE_GRANT', `Spend limit: ${remaining} udvpn remaining`);
457
+ }
458
+ }
459
+
460
+ // Check allowed_messages — verify it includes the messages we need
461
+ const allowedMessages = isAllowedMsg ? (outerAllowance.allowed_messages || []) : [];
462
+ if (allowedMessages.length > 0) {
463
+ const needsStart = allowedMessages.some(m =>
464
+ m.includes('MsgStartSession') || m.includes('MsgStartSessionRequest'),
465
+ );
466
+ if (!needsStart) {
467
+ log(3, totalSteps, 'FEE_GRANT', `Warning: allowed_messages doesn't include MsgStartSession — TX may fail`);
468
+ }
469
+ }
470
+ } catch (err) {
471
+ if (err.code === 'FEE_GRANT_NOT_FOUND' || err.code === 'FEE_GRANT_EXPIRED' || err.code === 'FEE_GRANT_EXHAUSTED') throw err;
472
+ // Non-critical — LCD query failed but fee grant may still work at broadcast time
473
+ log(3, totalSteps, 'FEE_GRANT', `Could not verify fee grant (${err.message}) — proceeding anyway`);
474
+ }
475
+ }
476
+
367
477
  // ── STEP 4/7: Node Selection ──────────────────────────────────────────────
368
478
 
369
479
  t0 = Date.now();
@@ -584,11 +694,13 @@ export async function connect(opts = {}) {
584
694
  } else if (resolvedNodeAddress) {
585
695
  // Direct connection — either user specified nodeAddress or country discovery found one
586
696
  sdkOpts.nodeAddress = resolvedNodeAddress;
697
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
587
698
  result = await connectDirect(sdkOpts);
588
699
  } else {
589
700
  // No country filter or country discovery found nothing — auto-select globally
590
701
  // Use higher maxAttempts to search more nodes
591
702
  if (!sdkOpts.maxAttempts) sdkOpts.maxAttempts = 5;
703
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
592
704
  result = await connectAuto(sdkOpts);
593
705
  }
594
706
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import {
8
8
  estimateSessionCost,
9
- estimateSessionPrice,
9
+ getNodePrices,
10
10
  formatP2P,
11
11
  PRICING_REFERENCE,
12
12
  DENOM,
@@ -81,11 +81,12 @@ export async function estimateCost(opts = {}) {
81
81
  const gb = opts.gigabytes || 1;
82
82
  const gasCost = 40000; // ~0.04 P2P per TX
83
83
 
84
- // If specific node given, get exact price
84
+ // If specific node given, get exact price via RPC-first node query
85
85
  if (opts.nodeAddress) {
86
86
  try {
87
- const price = await estimateSessionPrice(opts.nodeAddress, gb);
88
- const total = price.udvpn || price.amount || 0;
87
+ const prices = await getNodePrices(opts.nodeAddress);
88
+ const perGbUdvpn = prices.gigabyte.udvpn || 0;
89
+ const total = perGbUdvpn * gb;
89
90
  const grandTotal = total + gasCost;
90
91
 
91
92
  const result = {
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,43 @@ 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
+ } from './rpc.js';
19
34
 
20
35
  // Re-export for convenience
21
36
  export { extractSessionId };
22
37
 
38
+ // ─── RPC Client Helper ─────────────────────────────────────────────────────
39
+
40
+ let _rpcClient = null;
41
+ let _rpcClientPromise = null;
42
+
43
+ /**
44
+ * Get or create a cached RPC query client. Returns null if all RPC endpoints fail
45
+ * (caller should fall back to LCD).
46
+ */
47
+ async function getRpcClient() {
48
+ if (_rpcClient) return _rpcClient;
49
+ if (_rpcClientPromise) return _rpcClientPromise;
50
+ _rpcClientPromise = createRpcQueryClientWithFallback()
51
+ .then(client => { _rpcClient = client; return client; })
52
+ .catch(() => { _rpcClient = null; return null; })
53
+ .finally(() => { _rpcClientPromise = null; });
54
+ return _rpcClientPromise;
55
+ }
56
+
23
57
  // ─── Query Helpers ───────────────────────────────────────────────────────────
24
58
 
25
59
  /**
@@ -35,20 +69,38 @@ export async function getBalance(client, address) {
35
69
  /**
36
70
  * Find an existing active session for a wallet+node pair.
37
71
  * 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.
72
+ * RPC-first with LCD fallback.
40
73
  */
41
74
  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;
75
+ let sessions;
76
+
77
+ // RPC-first: returns decoded, flat session objects
78
+ try {
79
+ const rpc = await getRpcClient();
80
+ if (rpc) {
81
+ sessions = await rpcQuerySessionsForAccount(rpc, walletAddr, { limit: 500 });
82
+ }
83
+ } catch { /* RPC failed, fall through to LCD */ }
84
+
85
+ if (!sessions) {
86
+ // LCD fallback
87
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`, 'sessions');
88
+ sessions = items.map(s => {
89
+ const bs = s.base_session || s;
90
+ return { ...bs, price: s.price, subscription_id: s.subscription_id };
91
+ });
92
+ }
93
+
94
+ for (const s of sessions) {
95
+ if ((s.node_address || s.node) !== nodeAddr) continue;
96
+ // RPC returns status as number (1=active), LCD as string
97
+ const st = s.status;
98
+ if (st && st !== 1 && st !== 'active') continue;
99
+ const acct = s.acc_address || s.address;
48
100
  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);
101
+ const maxBytes = parseInt(s.max_bytes || '0');
102
+ const used = parseInt(s.download_bytes || '0') + parseInt(s.upload_bytes || '0');
103
+ if (maxBytes === 0 || used < maxBytes) return BigInt(s.id);
52
104
  }
53
105
  return null;
54
106
  }
@@ -80,7 +132,7 @@ const NODE_CACHE_TTL = 5 * 60_000; // 5 minutes
80
132
  export function invalidateNodeCache() { _nodeListCache = null; }
81
133
 
82
134
  /**
83
- * Fetch all active nodes from LCD with pagination.
135
+ * Fetch all active nodes via RPC (primary) with LCD fallback.
84
136
  * Returns array of node objects. Each node has:
85
137
  * - `remote_url`: the first usable HTTPS URL (for primary connection)
86
138
  * - `remoteAddrs`: ALL remote addresses (for fallback on connection failure)
@@ -93,7 +145,21 @@ export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
93
145
  return _nodeListCache.map(n => ({ ...n, planIds: [...(n.planIds || [])] }));
94
146
  }
95
147
 
96
- const { items } = await lcdPaginatedSafe(lcdUrl, '/sentinel/node/v3/nodes?status=1', 'nodes', { limit });
148
+ let items;
149
+ try {
150
+ // RPC-first: ~912x faster than LCD for bulk node queries
151
+ const rpc = await getRpcClient();
152
+ if (rpc) {
153
+ items = await rpcQueryNodes(rpc, { status: 1, limit });
154
+ }
155
+ } catch { /* RPC failed, fall through to LCD */ }
156
+
157
+ if (!items) {
158
+ // LCD fallback
159
+ const result = await lcdPaginatedSafe(lcdUrl, '/sentinel/node/v3/nodes?status=1', 'nodes', { limit });
160
+ items = result.items;
161
+ }
162
+
97
163
  for (const n of items) {
98
164
  // Preserve ALL remote addresses for fallback
99
165
  const addrs = n.remote_addrs || [];
@@ -228,21 +294,35 @@ export async function getNodePrices(nodeAddress, lcdUrl) {
228
294
 
229
295
  /**
230
296
  * Query a wallet's active subscriptions.
297
+ * RPC-first with LCD fallback.
231
298
  * @param {string} lcdUrl
232
299
  * @param {string} walletAddr - sent1... address
233
- * @returns {Promise<{ subscriptions: any[], total: number|null }>}
300
+ * @returns {Promise<{ items: any[], total: number|null }>}
234
301
  */
235
302
  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' });
303
+ // RPC-first
304
+ try {
305
+ const rpc = await getRpcClient();
306
+ if (rpc) {
307
+ let subs = await rpcQuerySubscriptionsForAccount(rpc, walletAddr, { limit: 500 });
308
+ if (opts.status) {
309
+ const statusNum = opts.status === 'active' ? 1 : 2;
310
+ subs = subs.filter(s => s.status === statusNum);
311
+ }
312
+ return { items: subs, total: subs.length };
313
+ }
314
+ } catch { /* fall through to LCD */ }
315
+
316
+ // LCD fallback
317
+ let lcdPath = `/sentinel/subscription/v3/accounts/${walletAddr}/subscriptions`;
318
+ if (opts.status) lcdPath += `?status=${opts.status === 'active' ? '1' : '2'}`;
319
+ return lcdQueryAll(lcdPath, { lcdUrl, dataKey: 'subscriptions' });
240
320
  }
241
321
 
242
322
  /**
243
323
  * Query a single session directly by ID — O(1) instead of scanning all wallet sessions.
244
324
  * 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).
325
+ * RPC-first with LCD fallback.
246
326
  *
247
327
  * @param {string} lcdUrl - LCD endpoint URL
248
328
  * @param {string|number|bigint} sessionId - Session ID to query
@@ -253,6 +333,16 @@ export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
253
333
  * if (session) console.log(`Session ${session.id} on node ${session.node_address}`);
254
334
  */
255
335
  export async function querySessionById(lcdUrl, sessionId) {
336
+ // RPC-first
337
+ try {
338
+ const rpc = await getRpcClient();
339
+ if (rpc) {
340
+ const session = await rpcQuerySession(rpc, sessionId);
341
+ if (session) return session;
342
+ }
343
+ } catch { /* fall through to LCD */ }
344
+
345
+ // LCD fallback
256
346
  try {
257
347
  const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
258
348
  const raw = data?.session;
@@ -263,30 +353,45 @@ export async function querySessionById(lcdUrl, sessionId) {
263
353
 
264
354
  /**
265
355
  * Query session allocation (remaining bandwidth).
356
+ * RPC-first with LCD fallback.
266
357
  * @param {string} lcdUrl
267
358
  * @param {string|number|bigint} sessionId
268
359
  * @returns {Promise<{ maxBytes: number, usedBytes: number, remainingBytes: number, percentUsed: number }|null>}
269
360
  */
270
361
  export async function querySessionAllocation(lcdUrl, sessionId) {
362
+ let s = null;
363
+
364
+ // RPC-first: query session by ID returns flat decoded object
271
365
  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; }
366
+ const rpc = await getRpcClient();
367
+ if (rpc) {
368
+ s = await rpcQuerySession(rpc, sessionId);
369
+ }
370
+ } catch { /* fall through */ }
371
+
372
+ if (!s) {
373
+ // LCD fallback
374
+ try {
375
+ const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
376
+ s = data.session?.base_session || data.session || null;
377
+ } catch { return null; }
378
+ }
379
+
380
+ if (!s) return null;
381
+ const maxBytes = parseInt(s.max_bytes || '0', 10);
382
+ const dl = parseInt(s.download_bytes || '0', 10);
383
+ const ul = parseInt(s.upload_bytes || '0', 10);
384
+ const usedBytes = dl + ul;
385
+ return {
386
+ maxBytes,
387
+ usedBytes,
388
+ remainingBytes: Math.max(0, maxBytes - usedBytes),
389
+ percentUsed: maxBytes > 0 ? Math.round((usedBytes / maxBytes) * 100) : 0,
390
+ };
285
391
  }
286
392
 
287
393
  /**
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.
394
+ * Fetch a single node by address via RPC (primary) with LCD fallback.
290
395
  *
291
396
  * @param {string} nodeAddress - sentnode1... address
292
397
  * @param {object} [opts]
@@ -298,6 +403,21 @@ export async function queryNode(nodeAddress, opts = {}) {
298
403
  throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be sentnode1...', { nodeAddress });
299
404
  }
300
405
 
406
+ // RPC-first: single node query is fast and avoids LCD rate limits
407
+ try {
408
+ const rpc = await getRpcClient();
409
+ if (rpc) {
410
+ const node = await rpcQueryNode(rpc, nodeAddress);
411
+ if (node) {
412
+ node.remote_url = resolveNodeUrl(node);
413
+ const addrs = node.remote_addrs || [];
414
+ node.remoteAddrs = addrs.map(a => a.startsWith('http') ? a : `https://${a}`);
415
+ return node;
416
+ }
417
+ }
418
+ } catch { /* RPC failed, fall through to LCD */ }
419
+
420
+ // LCD fallback
301
421
  const fetchDirect = async (baseUrl) => {
302
422
  try {
303
423
  const data = await lcdQuery(`/sentinel/node/v3/nodes/${nodeAddress}`, { lcdUrl: baseUrl });
@@ -308,18 +428,19 @@ export async function queryNode(nodeAddress, opts = {}) {
308
428
  } catch { /* fall through to full list */ }
309
429
  const { items } = await lcdPaginatedSafe(baseUrl, '/sentinel/node/v3/nodes?status=1', 'nodes');
310
430
  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 });
431
+ if (!found) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found (may be inactive)`, { nodeAddress });
312
432
  found.remote_url = resolveNodeUrl(found);
313
433
  return found;
314
434
  };
315
435
 
316
436
  if (opts.lcdUrl) return fetchDirect(opts.lcdUrl);
317
- const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `LCD node lookup ${nodeAddress}`);
437
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `node lookup ${nodeAddress}`);
318
438
  return result;
319
439
  }
320
440
 
321
441
  /**
322
442
  * List all sessions for a wallet address.
443
+ * RPC-first with LCD fallback.
323
444
  * @param {string} address - sent1... wallet address
324
445
  * @param {string} [lcdUrl]
325
446
  * @param {object} [opts]
@@ -327,10 +448,25 @@ export async function queryNode(nodeAddress, opts = {}) {
327
448
  * @returns {Promise<{ items: ChainSession[], total: number }>}
328
449
  */
329
450
  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
451
+ // RPC-first: returns already-flat decoded sessions
452
+ try {
453
+ const rpc = await getRpcClient();
454
+ if (rpc) {
455
+ const sessions = await rpcQuerySessionsForAccount(rpc, address, { limit: 500 });
456
+ // Filter by status if requested (RPC returns all statuses)
457
+ let items = sessions;
458
+ if (opts.status) {
459
+ const statusNum = parseInt(opts.status, 10);
460
+ items = sessions.filter(s => s.status === statusNum);
461
+ }
462
+ return { items, total: items.length };
463
+ }
464
+ } catch { /* fall through to LCD */ }
465
+
466
+ // LCD fallback
467
+ let lcdPath = `/sentinel/session/v3/sessions?address=${address}`;
468
+ if (opts.status) lcdPath += `&status=${opts.status}`;
469
+ const result = await lcdPaginatedSafe(lcdUrl, lcdPath, 'sessions');
334
470
  result.items = result.items.map(flattenSession);
335
471
  return result;
336
472
  }
@@ -369,11 +505,22 @@ export function flattenSession(session) {
369
505
 
370
506
  /**
371
507
  * Get a single subscription by ID.
508
+ * RPC-first with LCD fallback.
372
509
  * @param {string|number} id - Subscription ID
373
510
  * @param {string} [lcdUrl]
374
511
  * @returns {Promise<Subscription|null>}
375
512
  */
376
513
  export async function querySubscription(id, lcdUrl) {
514
+ // RPC-first
515
+ try {
516
+ const rpc = await getRpcClient();
517
+ if (rpc) {
518
+ const sub = await rpcQuerySubscription(rpc, id);
519
+ if (sub) return sub;
520
+ }
521
+ } catch { /* fall through */ }
522
+
523
+ // LCD fallback
377
524
  try {
378
525
  const data = await lcdQuery(`/sentinel/subscription/v3/subscriptions/${id}`, { lcdUrl });
379
526
  return data.subscription || null;
@@ -382,6 +529,7 @@ export async function querySubscription(id, lcdUrl) {
382
529
 
383
530
  /**
384
531
  * Check if wallet has an active subscription for a specific plan.
532
+ * Uses querySubscriptions which is already RPC-first.
385
533
  * @param {string} address - sent1... wallet address
386
534
  * @param {number|string} planId - Plan ID to check
387
535
  * @param {string} [lcdUrl]
@@ -396,14 +544,28 @@ export async function hasActiveSubscription(address, planId, lcdUrl) {
396
544
 
397
545
  /**
398
546
  * 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}.
547
+ * RPC-first with LCD fallback. Uses v2 allocation path (v3 returns 501 on chain).
401
548
  *
402
549
  * @param {string|number|bigint} subscriptionId
403
550
  * @param {string} [lcdUrl]
404
551
  * @returns {Promise<Array<{ id: string, address: string, grantedBytes: string, utilisedBytes: string }>>}
405
552
  */
406
553
  export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
554
+ // RPC-first
555
+ try {
556
+ const rpc = await getRpcClient();
557
+ if (rpc) {
558
+ const allocs = await rpcQuerySubAllocations(rpc, subscriptionId, { limit: 100 });
559
+ return allocs.map(a => ({
560
+ id: a.id,
561
+ address: a.address,
562
+ grantedBytes: a.granted_bytes || '0',
563
+ utilisedBytes: a.utilised_bytes || '0',
564
+ }));
565
+ }
566
+ } catch { /* fall through */ }
567
+
568
+ // LCD fallback
407
569
  try {
408
570
  const data = await lcdQuery(`/sentinel/subscription/v2/subscriptions/${subscriptionId}/allocations`, { lcdUrl });
409
571
  return (data.allocations || []).map(a => ({
@@ -419,6 +581,7 @@ export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
419
581
 
420
582
  /**
421
583
  * Query all subscriptions for a plan. Supports owner filtering.
584
+ * RPC-first with LCD fallback.
422
585
  *
423
586
  * @param {number|string} planId - Plan ID
424
587
  * @param {object} [opts]
@@ -427,12 +590,30 @@ export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
427
590
  * @returns {Promise<{ subscribers: Array<{ address: string, status: number, id: string }>, total: number|null }>}
428
591
  */
429
592
  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
- );
593
+ let items;
594
+ let total;
595
+
596
+ // RPC-first
597
+ try {
598
+ const rpc = await getRpcClient();
599
+ if (rpc) {
600
+ items = await rpcQuerySubscriptionsForPlan(rpc, planId, { limit: 500 });
601
+ total = items.length;
602
+ }
603
+ } catch { /* fall through */ }
604
+
605
+ if (!items) {
606
+ // LCD fallback
607
+ const result = await lcdQueryAll(
608
+ `/sentinel/subscription/v3/plans/${planId}/subscriptions`,
609
+ { lcdUrl: opts.lcdUrl, dataKey: 'subscriptions' },
610
+ );
611
+ items = result.items;
612
+ total = result.total;
613
+ }
614
+
434
615
  let subscribers = items.map(s => ({
435
- address: s.address || s.subscriber,
616
+ address: s.address || s.acc_address || s.subscriber,
436
617
  status: s.status,
437
618
  id: s.id || s.base_id,
438
619
  ...s,
@@ -466,20 +647,28 @@ export async function getPlanStats(planId, ownerAddress, opts = {}) {
466
647
  // ─── v26: Field Experience Helpers ────────────────────────────────────────────
467
648
 
468
649
  /**
469
- * Query all nodes linked to a plan.
650
+ * Query all nodes linked to a plan via RPC (primary) with LCD fallback.
470
651
  * @param {number|string} planId
471
652
  * @param {string} [lcdUrl]
472
653
  * @returns {Promise<{ items: any[], total: number|null }>}
473
654
  */
474
655
  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.
656
+ // RPC-first
657
+ try {
658
+ const rpc = await getRpcClient();
659
+ if (rpc) {
660
+ const nodes = await rpcQueryNodesForPlan(rpc, planId, { status: 1, limit: 5000 });
661
+ return { items: nodes, total: nodes.length };
662
+ }
663
+ } catch { /* RPC failed, fall through to LCD */ }
664
+
665
+ // LCD fallback — pagination is BROKEN on this endpoint, single high-limit request
477
666
  const doQuery = async (baseUrl) => {
478
667
  const data = await lcd(baseUrl, `/sentinel/node/v3/plans/${planId}/nodes?pagination.limit=5000`);
479
668
  return { items: data.nodes || [], total: (data.nodes || []).length };
480
669
  };
481
670
  if (lcdUrl) return doQuery(lcdUrl);
482
- const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD plan ${planId} nodes`);
671
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `plan ${planId} nodes`);
483
672
  return result;
484
673
  }
485
674