blue-js-sdk 2.4.0 → 2.7.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.
Files changed (58) hide show
  1. package/README.md +3 -3
  2. package/app-helpers.js +55 -0
  3. package/chain/broadcast.js +27 -0
  4. package/chain/fee-grants.js +271 -5
  5. package/chain/index.js +8 -2
  6. package/chain/queries.js +177 -3
  7. package/chain/rpc.js +117 -4
  8. package/cli.js +26 -5
  9. package/client.js +79 -7
  10. package/connection/connect.js +119 -21
  11. package/connection/disconnect.js +93 -12
  12. package/connection/index.js +2 -0
  13. package/connection/logger.js +66 -0
  14. package/connection/resilience.js +12 -7
  15. package/connection/state.js +21 -12
  16. package/connection/tunnel.js +24 -8
  17. package/cosmjs-setup.js +68 -2
  18. package/docs/PRIVY-INTEGRATION.md +177 -0
  19. package/errors.js +167 -0
  20. package/index.js +75 -2
  21. package/node-connect.js +190 -50
  22. package/operator.js +26 -0
  23. package/package.json +11 -11
  24. package/session-manager.js +68 -0
  25. package/speedtest.js +139 -0
  26. package/test-all-logic.js +8 -6
  27. package/test-e2e.js +138 -0
  28. package/test-mainnet.js +2 -2
  29. package/test-plan-connect-e2e.js +235 -0
  30. package/test-subscription-flows.js +14 -4
  31. package/types/connection.d.ts +6 -2
  32. package/types/index.d.ts +2 -2
  33. package/ai-path/ADMIN-ELEVATION.md +0 -116
  34. package/ai-path/AI-MANIFESTO.md +0 -185
  35. package/ai-path/BREAKING.md +0 -74
  36. package/ai-path/CHECKLIST.md +0 -619
  37. package/ai-path/CONNECTION-STEPS.md +0 -724
  38. package/ai-path/DECISION-TREE.md +0 -422
  39. package/ai-path/DEPENDENCIES.md +0 -459
  40. package/ai-path/E2E-FLOW.md +0 -1707
  41. package/ai-path/FAILURES.md +0 -410
  42. package/ai-path/GUIDE.md +0 -1315
  43. package/ai-path/README.md +0 -599
  44. package/ai-path/SPLIT-TUNNEL.md +0 -266
  45. package/ai-path/cli.js +0 -548
  46. package/ai-path/connect.js +0 -1028
  47. package/ai-path/discover.js +0 -178
  48. package/ai-path/environment.js +0 -266
  49. package/ai-path/errors.js +0 -86
  50. package/ai-path/examples/autonomous-agent.mjs +0 -220
  51. package/ai-path/examples/multi-region.mjs +0 -174
  52. package/ai-path/examples/one-shot.mjs +0 -31
  53. package/ai-path/index.js +0 -79
  54. package/ai-path/pricing.js +0 -137
  55. package/ai-path/recommend.js +0 -413
  56. package/ai-path/run-admin.vbs +0 -25
  57. package/ai-path/setup.js +0 -291
  58. package/ai-path/wallet.js +0 -137
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  JavaScript/TypeScript SDK for the [Sentinel](https://sentinel.co) decentralized VPN network. WireGuard + V2Ray tunnels, Cosmos blockchain, 900+ nodes. RPC queries, typed events, CosmJS compatible.
4
4
 
5
- **Also available:** [Blue C# SDK](https://github.com/Sentinel-Autonomybuilder/blue-csharp-sdk) | [Blue AI Connect](https://github.com/Sentinel-Autonomybuilder/blue-ai-connect) (zero-config wrapper for AI agents)
5
+ **Also available:** [Blue C# SDK](https://github.com/Sentinel-Autonomybuilder/blue-csharp-sdk) | [Blue Agent Connect](https://github.com/Sentinel-Autonomybuilder/blue-agent-connect) (zero-config wrapper for AI agents)
6
6
 
7
7
  ## Platform Support
8
8
 
@@ -18,7 +18,7 @@ JavaScript/TypeScript SDK for the [Sentinel](https://sentinel.co) decentralized
18
18
 
19
19
  ---
20
20
 
21
- > **For AI agents:** If you just want `connect()` with one function call, use [`blue-ai-connect`](https://www.npmjs.com/package/blue-ai-connect) instead.
21
+ > **For AI agents:** If you just want `connect()` with one function call, use [`blue-agent-connect`](https://www.npmjs.com/package/blue-agent-connect) instead.
22
22
 
23
23
  ## Install
24
24
 
@@ -46,7 +46,7 @@ await disconnect();
46
46
 
47
47
  ## For AI Agents
48
48
 
49
- Use [sentinel-ai-connect](https://www.npmjs.com/package/sentinel-ai-connect) — a zero-config wrapper with one function call from zero to encrypted tunnel.
49
+ Use [blue-agent-connect](https://www.npmjs.com/package/blue-agent-connect) — a zero-config wrapper with one function call from zero to encrypted tunnel.
50
50
 
51
51
  ## Features
52
52
 
package/app-helpers.js CHANGED
@@ -72,6 +72,15 @@ export const COUNTRY_MAP = Object.freeze({
72
72
  'pr': 'PR', 'cn': 'CN', 'sa': 'SA', 'kz': 'KZ', 'mn': 'MN', 'sk': 'SK',
73
73
  'al': 'AL', 'md': 'MD', 'jm': 'JM', 'bo': 'BO', 'ec': 'EC', 'uy': 'UY',
74
74
  'bh': 'BH', 'cd': 'CD',
75
+
76
+ // Central Asia
77
+ 'kyrgyzstan': 'KG', 'uzbekistan': 'UZ', 'tajikistan': 'TJ',
78
+ 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
79
+
80
+ // Balkans
81
+ 'bosnia and herzegovina': 'BA', 'north macedonia': 'MK', 'montenegro': 'ME',
82
+ 'kosovo': 'XK', 'slovenia': 'SI',
83
+ 'ba': 'BA', 'mk': 'MK', 'me': 'ME', 'xk': 'XK', 'si': 'SI',
75
84
  });
76
85
 
77
86
  /**
@@ -286,6 +295,52 @@ export function groupNodesByCountry(nodes) {
286
295
  });
287
296
  }
288
297
 
298
+ // ─── Country → Continent Map ────────────────────────────────────────────────
299
+ // Continent classification for every ISO code present in COUNTRY_MAP.
300
+ // Codes follow ISO 3166-1 alpha-2 regions: EU/AS/NA/SA/AF/OC (+ AN, ZZ).
301
+
302
+ export const CONTINENT_BY_CODE = Object.freeze({
303
+ // Europe
304
+ DE: 'EU', FR: 'EU', GB: 'EU', NL: 'EU', ES: 'EU', IT: 'EU', SE: 'EU', NO: 'EU',
305
+ FI: 'EU', CH: 'EU', AT: 'EU', IE: 'EU', PT: 'EU', CZ: 'EU', HU: 'EU', BG: 'EU',
306
+ GR: 'EU', UA: 'EU', RU: 'EU', RO: 'EU', PL: 'EU', TR: 'EU', LV: 'EU', LT: 'EU',
307
+ EE: 'EU', HR: 'EU', RS: 'EU', DK: 'EU', BE: 'EU', LU: 'EU', MT: 'EU', CY: 'EU',
308
+ IS: 'EU', SK: 'EU', AL: 'EU', MD: 'EU', BA: 'EU', MK: 'EU', ME: 'EU', XK: 'EU',
309
+ SI: 'EU', GE: 'EU',
310
+ // Asia
311
+ JP: 'AS', SG: 'AS', IN: 'AS', KR: 'AS', HK: 'AS', TW: 'AS', TH: 'AS', VN: 'AS',
312
+ ID: 'AS', PH: 'AS', MY: 'AS', BD: 'AS', PK: 'AS', CN: 'AS', SA: 'AS', KZ: 'AS',
313
+ MN: 'AS', IL: 'AS', AE: 'AS', KG: 'AS', UZ: 'AS', TJ: 'AS', BH: 'AS',
314
+ // North America
315
+ US: 'NA', CA: 'NA', MX: 'NA', GT: 'NA', PR: 'NA', JM: 'NA', CR: 'NA', PA: 'NA',
316
+ DO: 'NA', SV: 'NA', HN: 'NA', NI: 'NA', CU: 'NA', HT: 'NA', TT: 'NA',
317
+ // South America
318
+ BR: 'SA', AR: 'SA', CL: 'SA', CO: 'SA', PE: 'SA', VE: 'SA', BO: 'SA', EC: 'SA',
319
+ UY: 'SA', PY: 'SA',
320
+ // Africa
321
+ ZA: 'AF', NG: 'AF', EG: 'AF', KE: 'AF', MA: 'AF', CD: 'AF',
322
+ // Oceania
323
+ AU: 'OC', NZ: 'OC',
324
+ });
325
+
326
+ export const CONTINENT_NAMES = Object.freeze({
327
+ EU: 'Europe', AS: 'Asia', NA: 'North America', SA: 'South America',
328
+ AF: 'Africa', OC: 'Oceania', AN: 'Antarctica', ZZ: 'Unknown',
329
+ });
330
+
331
+ /**
332
+ * Map a country (name or ISO code) to a continent code.
333
+ *
334
+ * @param {string} country - Country name (any variant in COUNTRY_MAP) or 2-letter ISO code
335
+ * @returns {string|null} 'EU' | 'AS' | 'NA' | 'SA' | 'AF' | 'OC' | null
336
+ */
337
+ export function countryToContinent(country) {
338
+ if (!country) return null;
339
+ const code = country.length === 2 ? country.toUpperCase() : countryNameToCode(country);
340
+ if (!code) return null;
341
+ return CONTINENT_BY_CODE[code] || null;
342
+ }
343
+
289
344
  // ─── Session Duration Helpers ───────────────────────────────────────────────
290
345
 
291
346
  /** Common hour options for hourly session selection UI. */
@@ -142,6 +142,33 @@ export function createSafeBroadcaster(rpcUrl, wallet, signerAddress) {
142
142
  return { safeBroadcast, getClient, resetClient };
143
143
  }
144
144
 
145
+ // ─── Shared Broadcast Pool (per-key serialization) ──────────────────────────
146
+ // For servers handling multiple concurrent wallets. Each wallet address gets
147
+ // its own queue so wallet A's TX never blocks wallet B. Single-wallet
148
+ // consumers (CLI tools) can pass any string as key — behavior is identical
149
+ // to a single-instance createSafeBroadcaster.
150
+
151
+ const _globalQueues = new Map();
152
+
153
+ /**
154
+ * Serialize a broadcast behind a per-key queue without creating a full
155
+ * createSafeBroadcaster instance. Useful when the caller already has a
156
+ * signing client and only needs sequence-safe serialization per wallet.
157
+ *
158
+ * @param {string} key - Wallet address (or any unique string per logical sender)
159
+ * @param {() => Promise<any>} fn - Async function to serialize
160
+ * @returns {Promise<any>}
161
+ */
162
+ export function withBroadcastQueue(key, fn) {
163
+ const prev = _globalQueues.get(key) ?? Promise.resolve();
164
+ const p = prev.then(fn);
165
+ _globalQueues.set(key, p.catch(() => {}));
166
+ p.finally(() => {
167
+ if (_globalQueues.get(key) === p) _globalQueues.delete(key);
168
+ });
169
+ return p;
170
+ }
171
+
145
172
  /**
146
173
  * Broadcast a TX with fee paid by a granter (fee grant).
147
174
  * The grantee signs; the granter pays gas via their fee allowance.
@@ -185,6 +185,62 @@ export async function queryFeeGrant(lcdUrl, granter, grantee) {
185
185
  } catch { return null; } // 404 = no grant
186
186
  }
187
187
 
188
+ /**
189
+ * Builder-friendly fee-grant check. Returns a parsed, normalized status so
190
+ * callers don't have to walk nested `AllowedMsgAllowance` → `BasicAllowance`
191
+ * shapes. Use this before broadcasting plan/subscription sessions that rely on
192
+ * a granter paying gas.
193
+ *
194
+ * RPC-first with LCD fallback. Either both granter+grantee, or pre-fetched
195
+ * allowance object can be passed.
196
+ *
197
+ * @param {string} lcdUrl - LCD endpoint (for fallback)
198
+ * @param {string} granter - sent1... granter address
199
+ * @param {string} grantee - sent1... grantee address
200
+ * @returns {Promise<{ exists: boolean, expired: boolean, expiresAt: Date|null, spendLimit: Array<{denom:string,amount:string}>, allowedMessages: string[]|null, typeUrl: string, raw: object|null }>}
201
+ */
202
+ export async function checkFeeGrant(lcdUrl, granter, grantee) {
203
+ const allowance = await queryFeeGrant(lcdUrl, granter, grantee);
204
+ if (!allowance) {
205
+ return {
206
+ exists: false,
207
+ expired: false,
208
+ expiresAt: null,
209
+ spendLimit: [],
210
+ allowedMessages: null,
211
+ typeUrl: '',
212
+ raw: null,
213
+ };
214
+ }
215
+
216
+ // If shape is { granter, grantee, allowance: {...} } from rpcQueryFeeGrant
217
+ const inner = allowance.allowance || allowance;
218
+ const typeUrl = inner['@type'] || '';
219
+ let basic = inner;
220
+ let allowedMessages = null;
221
+ if (typeUrl.includes('AllowedMsgAllowance')) {
222
+ allowedMessages = inner.allowed_messages || [];
223
+ basic = inner.allowance || null;
224
+ }
225
+
226
+ const spendLimit = (basic?.spend_limit || []).map(c => ({
227
+ denom: c.denom,
228
+ amount: String(c.amount || '0'),
229
+ }));
230
+ const expiresAt = basic?.expiration ? new Date(basic.expiration) : null;
231
+ const expired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
232
+
233
+ return {
234
+ exists: true,
235
+ expired,
236
+ expiresAt,
237
+ spendLimit,
238
+ allowedMessages,
239
+ typeUrl,
240
+ raw: allowance,
241
+ };
242
+ }
243
+
188
244
  // ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
189
245
 
190
246
  /**
@@ -194,19 +250,26 @@ export async function queryFeeGrant(lcdUrl, granter, grantee) {
194
250
  * @param {number|string} planId
195
251
  * @param {object} opts
196
252
  * @param {string} opts.granterAddress - Who pays fees (typically plan owner)
197
- * @param {string} opts.lcdUrl - LCD endpoint
253
+ * @param {string} [opts.lcdUrl] - LCD endpoint (used as fallback when RPC unavailable)
254
+ * @param {boolean} [opts.preferRpc=true] - Force RPC for all queries, skip LCD URL for grant lookup
255
+ * @param {object} [opts.rpcClient] - Pre-built RPC client to inject (skips internal createRpcQueryClientWithFallback)
198
256
  * @param {object} [opts.grantOpts] - Options for buildFeeGrantMsg (spendLimit, expiration, allowedMessages)
199
257
  * @returns {Promise<{ msgs: Array, skipped: string[], newGrants: string[] }>} Messages ready for broadcast
200
258
  */
201
259
  export async function grantPlanSubscribers(planId, opts = {}) {
202
- const { granterAddress, lcdUrl, grantOpts = {} } = opts;
260
+ const { granterAddress, lcdUrl, preferRpc = true, rpcClient, grantOpts = {} } = opts;
203
261
  if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
204
262
 
205
- // Get subscribers
263
+ // Get subscribers — already RPC-first internally
206
264
  const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
207
265
 
208
- // Get existing grants ISSUED BY granter (not grants received)
209
- const existingGrants = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
266
+ // Get existing grants ISSUED BY granter.
267
+ // preferRpc=true: pass null lcdUrl so internal getRpcClient() path is taken first.
268
+ // preferRpc=false: pass lcdUrl so LCD is used (useful when RPC is blocked).
269
+ const grantLcdUrl = preferRpc ? null : (lcdUrl || LCD_ENDPOINTS[0].url);
270
+ const existingGrants = rpcClient
271
+ ? await _rpcQueryFeeGrantsIssued(rpcClient, granterAddress).catch(() => queryFeeGrantsIssued(grantLcdUrl, granterAddress))
272
+ : await queryFeeGrantsIssued(grantLcdUrl, granterAddress);
210
273
  const alreadyGranted = new Set(existingGrants.map(g => g.grantee));
211
274
 
212
275
  const msgs = [];
@@ -354,3 +417,206 @@ export function monitorFeeGrants(opts = {}) {
354
417
 
355
418
  return emitter;
356
419
  }
420
+
421
+ // ─── Streaming Batch Grant (for SSE / progress UIs) ──────────────────────────
422
+
423
+ /**
424
+ * Stream progress as we grant fee allowances to all plan subscribers in batches.
425
+ *
426
+ * Async generator. Yields events with `{ type, ...payload }`:
427
+ * - status { msg } — human-readable status line
428
+ * - batch_start { batch, total, count, addresses } — about to broadcast a batch
429
+ * - batch_ok { batch, total, granted, totalGranted, txHash, elapsed }
430
+ * - batch_error { batch, total, error, elapsed }
431
+ * - done { granted, skipped, total, errors? }
432
+ * - error { msg }
433
+ *
434
+ * The caller passes a `broadcast(msgs, memo)` function — any safe-broadcaster
435
+ * with the Plan Manager's mutex + sequence-retry semantics works. Consumer
436
+ * routes layer SSE (`res.write('data: ...\n\n')`) on top of these events.
437
+ *
438
+ * @param {number|string} planId
439
+ * @param {object} opts
440
+ * @param {string} opts.granterAddress - Plan owner paying fees
441
+ * @param {string} [opts.lcdUrl] - LCD endpoint (used as fallback when RPC unavailable)
442
+ * @param {boolean} [opts.preferRpc=true] - Force RPC for all queries, skip LCD URL for grant lookup
443
+ * @param {object} [opts.rpcClient] - Pre-built RPC client to inject (skips internal createRpcQueryClientWithFallback)
444
+ * @param {(msgs: Array, memo: string) => Promise<{code:number, rawLog?:string, transactionHash?:string}>} opts.broadcast
445
+ * @param {object} [opts.grantOpts] - { spendLimit, expiration } for BasicAllowance
446
+ * @param {number} [opts.batchSize=5] - Msgs per TX
447
+ * @param {() => boolean} [opts.isCancelled] - Return true to abort between batches
448
+ * @yields {{type: string, [key: string]: any}}
449
+ */
450
+ export async function* streamGrantPlanSubscribers(planId, opts = {}) {
451
+ const {
452
+ granterAddress,
453
+ lcdUrl,
454
+ preferRpc = true,
455
+ rpcClient,
456
+ broadcast,
457
+ grantOpts = {},
458
+ batchSize = 5,
459
+ isCancelled = () => false,
460
+ } = opts;
461
+
462
+ if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
463
+ if (typeof broadcast !== 'function') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'broadcast function is required');
464
+
465
+ try {
466
+ yield { type: 'status', msg: 'Fetching plan subscribers...' };
467
+ const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
468
+
469
+ const now = new Date();
470
+ const activeSubs = subscribers.filter(s => {
471
+ if (s.status && s.status !== 'active') return false;
472
+ if (s.inactive_at && new Date(s.inactive_at) <= now) return false;
473
+ return true;
474
+ });
475
+ const uniqueAddrs = [...new Set(activeSubs.map(s => s.acc_address || s.address))]
476
+ .filter(a => a && a !== granterAddress && !isSameKey(a, granterAddress));
477
+
478
+ yield { type: 'status', msg: `Found ${activeSubs.length} active subscribers (${uniqueAddrs.length} unique, excl. self)` };
479
+
480
+ if (uniqueAddrs.length === 0) {
481
+ yield { type: 'done', granted: 0, skipped: 0, total: 0, msg: 'No active subscribers (excluding self)' };
482
+ return;
483
+ }
484
+
485
+ yield { type: 'status', msg: 'Checking existing grants...' };
486
+ const streamLcdUrl = preferRpc ? null : (lcdUrl || LCD_ENDPOINTS[0].url);
487
+ const existing = rpcClient
488
+ ? await _rpcQueryFeeGrantsIssued(rpcClient, granterAddress).catch(() => queryFeeGrantsIssued(streamLcdUrl, granterAddress))
489
+ : await queryFeeGrantsIssued(streamLcdUrl, granterAddress);
490
+ const existingGrantees = new Set(existing.map(g => g.grantee));
491
+ const needGrant = uniqueAddrs.filter(a => !existingGrantees.has(a));
492
+ const skipped = uniqueAddrs.length - needGrant.length;
493
+
494
+ yield { type: 'status', msg: `${existingGrantees.size} existing grants found. ${needGrant.length} need granting, ${skipped} already covered.` };
495
+
496
+ if (needGrant.length === 0) {
497
+ yield { type: 'done', granted: 0, skipped, total: 0, msg: 'All subscribers already have grants' };
498
+ return;
499
+ }
500
+
501
+ const totalBatches = Math.ceil(needGrant.length / batchSize);
502
+ let granted = 0;
503
+ const errors = [];
504
+
505
+ for (let i = 0; i < needGrant.length; i += batchSize) {
506
+ if (isCancelled()) break;
507
+
508
+ const batchNum = Math.floor(i / batchSize) + 1;
509
+ const batch = needGrant.slice(i, i + batchSize);
510
+ const shortAddrs = batch.map(a => a.slice(0, 12) + '...' + a.slice(-6)).join(', ');
511
+
512
+ yield { type: 'batch_start', batch: batchNum, total: totalBatches, count: batch.length, addresses: shortAddrs };
513
+
514
+ const msgs = batch.map(grantee => buildFeeGrantMsg(granterAddress, grantee, grantOpts));
515
+
516
+ const t0 = Date.now();
517
+ try {
518
+ const result = await broadcast(msgs, `Fee grant batch ${batchNum}/${totalBatches}`);
519
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
520
+
521
+ if (result.code !== 0) {
522
+ const errMsg = result.rawLog || `TX failed code=${result.code}`;
523
+ yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: errMsg, elapsed };
524
+ errors.push(`Batch ${batchNum}: ${errMsg}`);
525
+ } else {
526
+ granted += batch.length;
527
+ yield {
528
+ type: 'batch_ok',
529
+ batch: batchNum,
530
+ total: totalBatches,
531
+ granted: batch.length,
532
+ totalGranted: granted,
533
+ txHash: result.transactionHash,
534
+ elapsed,
535
+ };
536
+ }
537
+ } catch (e) {
538
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
539
+ yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: e.message, elapsed };
540
+ errors.push(`Batch ${batchNum}: ${e.message}`);
541
+ }
542
+ }
543
+
544
+ yield {
545
+ type: 'done',
546
+ granted,
547
+ skipped,
548
+ total: needGrant.length,
549
+ errors: errors.length ? errors : undefined,
550
+ };
551
+ } catch (e) {
552
+ yield { type: 'error', msg: e.message };
553
+ }
554
+ }
555
+
556
+ // ─── Gas Cost Analytics ──────────────────────────────────────────────────────
557
+
558
+ /**
559
+ * Compute how many udvpn the granter has spent on fee-granted transactions
560
+ * for a plan's subscribers. Iterates each subscriber, pulls their outgoing
561
+ * TXs via LCD, and sums fees where `fee.granter === granterAddress`.
562
+ *
563
+ * @param {number|string} planId
564
+ * @param {object} opts
565
+ * @param {string} opts.granterAddress - Address that paid the fees (plan owner)
566
+ * @param {string} opts.lcdUrl - LCD endpoint
567
+ * @param {number} [opts.txLimit=100] - Max TXs to inspect per subscriber
568
+ * @param {(info: {processed:number, total:number, address:string}) => void} [opts.onProgress]
569
+ * @returns {Promise<{ totalUdvpn: number, txCount: number, byAddress: Record<string, {udvpn:number, txCount:number}>, subscriberCount: number }>}
570
+ */
571
+ export async function computeFeeGrantGasCosts(planId, opts = {}) {
572
+ const { granterAddress, lcdUrl, txLimit = 100, onProgress } = opts;
573
+ if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
574
+
575
+ const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
576
+ const subscriberAddrs = [...new Set(subscribers.map(s => s.acc_address || s.address))]
577
+ .filter(a => a && a !== granterAddress);
578
+
579
+ if (subscriberAddrs.length === 0) {
580
+ return { totalUdvpn: 0, txCount: 0, byAddress: {}, subscriberCount: 0 };
581
+ }
582
+
583
+ let totalUdvpn = 0;
584
+ let txCount = 0;
585
+ const byAddress = {};
586
+
587
+ const base = lcdUrl || LCD_ENDPOINTS[0].url;
588
+ for (let idx = 0; idx < subscriberAddrs.length; idx++) {
589
+ const addr = subscriberAddrs[idx];
590
+ try {
591
+ const path =
592
+ `/cosmos/tx/v1beta1/txs?events=${encodeURIComponent("message.sender='" + addr + "'")}` +
593
+ `&pagination.limit=${txLimit}&order_by=2`;
594
+ const txData = await lcdQuery(path, { lcdUrl: base });
595
+ const rawTxs = txData.txs || [];
596
+
597
+ let addrGas = 0;
598
+ let addrTxCount = 0;
599
+
600
+ for (const tx of rawTxs) {
601
+ const fee = tx?.auth_info?.fee;
602
+ if (fee?.granter === granterAddress) {
603
+ const udvpnFee = (fee.amount || []).find(f => f.denom === 'udvpn');
604
+ if (udvpnFee) {
605
+ addrGas += parseInt(udvpnFee.amount, 10);
606
+ addrTxCount++;
607
+ }
608
+ }
609
+ }
610
+
611
+ if (addrTxCount > 0) {
612
+ byAddress[addr] = { udvpn: addrGas, txCount: addrTxCount };
613
+ totalUdvpn += addrGas;
614
+ txCount += addrTxCount;
615
+ }
616
+ } catch { /* skip this subscriber on LCD failure */ }
617
+
618
+ if (onProgress) onProgress({ processed: idx + 1, total: subscriberAddrs.length, address: addr });
619
+ }
620
+
621
+ return { totalUdvpn, txCount, byAddress, subscriberCount: subscriberAddrs.length };
622
+ }
package/chain/index.js CHANGED
@@ -359,9 +359,15 @@ export function txResponse(result) {
359
359
  * Find an existing active session for a wallet+node pair.
360
360
  * Returns session ID (BigInt) or null. Use this to avoid double-paying.
361
361
  * Delegates to chain/queries.js RPC-first implementation.
362
+ *
363
+ * @param {string} lcdUrl - LCD endpoint URL
364
+ * @param {string} walletAddr - sent1... wallet address
365
+ * @param {string} nodeAddr - sentnode1... node address
366
+ * @param {object} [opts] - Optional. Pass `{ onStaleDuplicate: (BigInt) => void }` to
367
+ * receive fire-and-forget cancellation callbacks for stale duplicate sessions.
362
368
  */
363
- export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
364
- return _rpcFindExistingSession(lcdUrl, walletAddr, nodeAddr);
369
+ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts) {
370
+ return _rpcFindExistingSession(lcdUrl, walletAddr, nodeAddr, opts);
365
371
  }
366
372
 
367
373
  /**
package/chain/queries.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  rpcQueryBalance,
33
33
  rpcQueryProvider as _rpcQueryProvider,
34
34
  rpcQueryAuthzGrants as _rpcQueryAuthzGrants,
35
+ rpcGetTxByHash,
35
36
  } from './rpc.js';
36
37
 
37
38
  // Re-export for convenience
@@ -81,8 +82,22 @@ export async function getBalance(client, address) {
81
82
  * Find an existing active session for a wallet+node pair.
82
83
  * Returns session ID (BigInt) or null. Use this to avoid double-paying.
83
84
  * RPC-first with LCD fallback.
85
+ *
86
+ * Dedup: if multiple active sessions exist for the same node_address (stale
87
+ * duplicates from crashes or multi-client wallets), the one with the HIGHEST
88
+ * session ID is returned. All lower-ID duplicates are passed to `onStaleDuplicate`
89
+ * (if provided) for fire-and-forget cancellation.
90
+ *
91
+ * @param {string} lcdUrl - LCD endpoint URL
92
+ * @param {string} walletAddr - sent1... wallet address
93
+ * @param {string} nodeAddr - sentnode1... node address
94
+ * @param {object} [opts]
95
+ * @param {function} [opts.onStaleDuplicate] - Called with (BigInt sessionId) for each
96
+ * stale lower-ID duplicate session. Caller is responsible for fire-and-forget
97
+ * MsgCancelSession. Keeps chain/queries.js dependency-free of signing/broadcast logic.
98
+ * @returns {Promise<BigInt|null>}
84
99
  */
85
- export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
100
+ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts = {}) {
86
101
  let sessions;
87
102
 
88
103
  // RPC-first: returns decoded, flat session objects
@@ -102,6 +117,8 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
102
117
  });
103
118
  }
104
119
 
120
+ // Collect all non-exhausted active sessions for this node
121
+ const matching = [];
105
122
  for (const s of sessions) {
106
123
  if ((s.node_address || s.node) !== nodeAddr) continue;
107
124
  // RPC returns status as number (1=active), LCD as string
@@ -111,9 +128,22 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
111
128
  if (acct && acct !== walletAddr) continue;
112
129
  const maxBytes = parseInt(s.max_bytes || '0');
113
130
  const used = parseInt(s.download_bytes || '0') + parseInt(s.upload_bytes || '0');
114
- if (maxBytes === 0 || used < maxBytes) return BigInt(s.id);
131
+ if (maxBytes === 0 || used < maxBytes) matching.push(BigInt(s.id));
132
+ }
133
+
134
+ if (matching.length === 0) return null;
135
+
136
+ // Sort descending — highest session ID is the freshest (most recent MsgStartSession)
137
+ matching.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));
138
+
139
+ // Dedup: cancel stale lower-ID duplicates (fire-and-forget via caller callback)
140
+ if (matching.length > 1 && typeof opts.onStaleDuplicate === 'function') {
141
+ for (let i = 1; i < matching.length; i++) {
142
+ opts.onStaleDuplicate(matching[i]);
143
+ }
115
144
  }
116
- return null;
145
+
146
+ return matching[0];
117
147
  }
118
148
 
119
149
  /**
@@ -588,8 +618,80 @@ export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
588
618
  } catch { return []; }
589
619
  }
590
620
 
621
+ /**
622
+ * Normalize chain status values from RPC (numeric: 1=active, 2=inactive, 3=pending)
623
+ * and LCD (strings: "STATUS_ACTIVE", "STATUS_INACTIVE", "STATUS_INACTIVE_PENDING").
624
+ * Returns true only for the ACTIVE status, never for INACTIVE_PENDING (status=3),
625
+ * which is a transient terminal state and should never be treated as connectable.
626
+ *
627
+ * @param {number|string|undefined} v
628
+ * @returns {boolean}
629
+ */
630
+ export function isActiveStatus(v) {
631
+ if (v === 1 || v === '1') return true;
632
+ if (typeof v === 'string') return v === 'STATUS_ACTIVE' || v === 'active';
633
+ return false;
634
+ }
635
+
591
636
  // ─── Plan Subscriber Helpers (v25b) ──────────────────────────────────────────
592
637
 
638
+ /**
639
+ * Query a single plan's metadata. RPC-first with LCD fallback.
640
+ *
641
+ * Returns the plan's provider (sentprov1...), price list, duration, bytes quota,
642
+ * and status. Builders use this before connecting through a plan subscription to
643
+ * resolve the plan owner (the address that typically acts as fee granter for
644
+ * MsgStartSubscriptionRequest + MsgPlanStartSession TXs).
645
+ *
646
+ * NOTE: `prov_address` is a sentprov1... provider address, which is usually
647
+ * derived from a sent1... account address. If you need the operator's sent1...
648
+ * account (the expected fee granter), either pass it in explicitly via app
649
+ * config or call `getProviderByAddress(prov_address)` and read `.address`.
650
+ *
651
+ * @param {number|string} planId
652
+ * @param {object} [opts]
653
+ * @param {string} [opts.lcdUrl]
654
+ * @returns {Promise<{ planId: string, provider: string, prices: Array, bytes: string, duration: string, status: number, statusAt: string|null, private: boolean } | null>}
655
+ */
656
+ export async function queryPlanDetails(planId, opts = {}) {
657
+ // RPC-first
658
+ try {
659
+ const rpc = await getRpcClient();
660
+ if (rpc) {
661
+ const plan = await rpcQueryPlan(rpc, planId);
662
+ if (plan) {
663
+ return {
664
+ planId: String(plan.id),
665
+ provider: plan.prov_address,
666
+ prices: plan.prices || [],
667
+ bytes: plan.bytes || '0',
668
+ duration: plan.duration || '0s',
669
+ status: plan.status,
670
+ statusAt: plan.status_at,
671
+ private: plan.private === true,
672
+ };
673
+ }
674
+ }
675
+ } catch { /* fall through to LCD */ }
676
+
677
+ // LCD fallback: /sentinel/plan/v3/plans/{planId}
678
+ try {
679
+ const data = await lcdQuery(`/sentinel/plan/v3/plans/${planId}`, { lcdUrl: opts.lcdUrl });
680
+ const plan = data?.plan;
681
+ if (!plan) return null;
682
+ return {
683
+ planId: String(plan.id),
684
+ provider: plan.prov_address || plan.provider_address || '',
685
+ prices: plan.prices || [],
686
+ bytes: plan.bytes || '0',
687
+ duration: plan.duration || '0s',
688
+ status: typeof plan.status === 'number' ? plan.status : (plan.status === 'STATUS_ACTIVE' ? 1 : 2),
689
+ statusAt: plan.status_at || null,
690
+ private: plan.private === true,
691
+ };
692
+ } catch { return null; }
693
+ }
694
+
593
695
  /**
594
696
  * Query all subscriptions for a plan. Supports owner filtering.
595
697
  * RPC-first with LCD fallback.
@@ -841,3 +943,75 @@ export function saveVpnSettings(settings) {
841
943
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
842
944
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
843
945
  }
946
+
947
+ // ─── TX Hash Lookup (RPC-first, LCD fallback) ───────────────────────────────
948
+
949
+ /**
950
+ * Fetch a transaction by hash. RPC is tried first; if it fails or the TX is
951
+ * not found, falls back to the LCD REST endpoint.
952
+ *
953
+ * Accepts bare hex or 0x-prefixed hex for the hash.
954
+ * Returns the same normalised shape regardless of source:
955
+ * { hash, height, code, rawLog, events, gasUsed, gasWanted }
956
+ *
957
+ * Use this to re-fetch TX events after a crash/restart or from a different
958
+ * process (CosmJS only returns DeliverTxResponse inline from signAndBroadcast).
959
+ *
960
+ * @param {string} txHash - Transaction hash (bare hex or 0x-prefixed)
961
+ * @param {object} [opts]
962
+ * @param {string} [opts.rpcUrl] - RPC endpoint (uses cached client if omitted)
963
+ * @param {string} [opts.lcdUrl] - LCD endpoint for fallback
964
+ * @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string } | null>}
965
+ */
966
+ export async function getTxByHash(txHash, opts = {}) {
967
+ const hex = txHash.replace(/^0x/i, '').toUpperCase();
968
+
969
+ // ── RPC-first ──────────────────────────────────────────────────────────────
970
+ try {
971
+ let rpc;
972
+ if (opts.rpcUrl) {
973
+ const { createRpcQueryClient } = await import('./rpc.js');
974
+ rpc = await createRpcQueryClient(opts.rpcUrl);
975
+ } else {
976
+ rpc = await getRpcClient();
977
+ }
978
+ if (rpc?.tmClient) {
979
+ const result = await rpcGetTxByHash(rpc.tmClient, hex);
980
+ return result;
981
+ }
982
+ } catch (rpcErr) {
983
+ // "tx not found" from RPC → fall through to LCD
984
+ const msg = rpcErr?.message || '';
985
+ if (!msg.toLowerCase().includes('not found') && !msg.toLowerCase().includes('404')) {
986
+ // Real connectivity error — still fall through, LCD may succeed
987
+ }
988
+ }
989
+
990
+ // ── LCD fallback ───────────────────────────────────────────────────────────
991
+ try {
992
+ const doLcd = async (baseUrl) => {
993
+ const data = await lcdQuery(`/cosmos/tx/v1beta1/txs/${hex}`, { lcdUrl: baseUrl });
994
+ const txResp = data?.tx_response;
995
+ if (!txResp) return null;
996
+ const events = (txResp.events || []).map(ev => ({
997
+ type: ev.type,
998
+ attributes: (ev.attributes || []).map(attr => ({
999
+ key: attr.key,
1000
+ value: attr.value,
1001
+ })),
1002
+ }));
1003
+ return {
1004
+ hash: (txResp.txhash || hex).toUpperCase(),
1005
+ height: parseInt(txResp.height || '0', 10),
1006
+ code: txResp.code || 0,
1007
+ rawLog: txResp.raw_log || '',
1008
+ events,
1009
+ gasUsed: String(txResp.gas_used || '0'),
1010
+ gasWanted: String(txResp.gas_wanted || '0'),
1011
+ };
1012
+ };
1013
+ if (opts.lcdUrl) return await doLcd(opts.lcdUrl);
1014
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, doLcd, `getTxByHash ${hex}`);
1015
+ return result;
1016
+ } catch { return null; }
1017
+ }