blue-js-sdk 2.4.0 → 2.6.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/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
 
@@ -354,3 +354,199 @@ export function monitorFeeGrants(opts = {}) {
354
354
 
355
355
  return emitter;
356
356
  }
357
+
358
+ // ─── Streaming Batch Grant (for SSE / progress UIs) ──────────────────────────
359
+
360
+ /**
361
+ * Stream progress as we grant fee allowances to all plan subscribers in batches.
362
+ *
363
+ * Async generator. Yields events with `{ type, ...payload }`:
364
+ * - status { msg } — human-readable status line
365
+ * - batch_start { batch, total, count, addresses } — about to broadcast a batch
366
+ * - batch_ok { batch, total, granted, totalGranted, txHash, elapsed }
367
+ * - batch_error { batch, total, error, elapsed }
368
+ * - done { granted, skipped, total, errors? }
369
+ * - error { msg }
370
+ *
371
+ * The caller passes a `broadcast(msgs, memo)` function — any safe-broadcaster
372
+ * with the Plan Manager's mutex + sequence-retry semantics works. Consumer
373
+ * routes layer SSE (`res.write('data: ...\n\n')`) on top of these events.
374
+ *
375
+ * @param {number|string} planId
376
+ * @param {object} opts
377
+ * @param {string} opts.granterAddress - Plan owner paying fees
378
+ * @param {string} opts.lcdUrl - LCD endpoint
379
+ * @param {(msgs: Array, memo: string) => Promise<{code:number, rawLog?:string, transactionHash?:string}>} opts.broadcast
380
+ * @param {object} [opts.grantOpts] - { spendLimit, expiration } for BasicAllowance
381
+ * @param {number} [opts.batchSize=5] - Msgs per TX
382
+ * @param {() => boolean} [opts.isCancelled] - Return true to abort between batches
383
+ * @yields {{type: string, [key: string]: any}}
384
+ */
385
+ export async function* streamGrantPlanSubscribers(planId, opts = {}) {
386
+ const {
387
+ granterAddress,
388
+ lcdUrl,
389
+ broadcast,
390
+ grantOpts = {},
391
+ batchSize = 5,
392
+ isCancelled = () => false,
393
+ } = opts;
394
+
395
+ if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
396
+ if (typeof broadcast !== 'function') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'broadcast function is required');
397
+
398
+ try {
399
+ yield { type: 'status', msg: 'Fetching plan subscribers...' };
400
+ const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
401
+
402
+ const now = new Date();
403
+ const activeSubs = subscribers.filter(s => {
404
+ if (s.status && s.status !== 'active') return false;
405
+ if (s.inactive_at && new Date(s.inactive_at) <= now) return false;
406
+ return true;
407
+ });
408
+ const uniqueAddrs = [...new Set(activeSubs.map(s => s.acc_address || s.address))]
409
+ .filter(a => a && a !== granterAddress && !isSameKey(a, granterAddress));
410
+
411
+ yield { type: 'status', msg: `Found ${activeSubs.length} active subscribers (${uniqueAddrs.length} unique, excl. self)` };
412
+
413
+ if (uniqueAddrs.length === 0) {
414
+ yield { type: 'done', granted: 0, skipped: 0, total: 0, msg: 'No active subscribers (excluding self)' };
415
+ return;
416
+ }
417
+
418
+ yield { type: 'status', msg: 'Checking existing grants...' };
419
+ const existing = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
420
+ const existingGrantees = new Set(existing.map(g => g.grantee));
421
+ const needGrant = uniqueAddrs.filter(a => !existingGrantees.has(a));
422
+ const skipped = uniqueAddrs.length - needGrant.length;
423
+
424
+ yield { type: 'status', msg: `${existingGrantees.size} existing grants found. ${needGrant.length} need granting, ${skipped} already covered.` };
425
+
426
+ if (needGrant.length === 0) {
427
+ yield { type: 'done', granted: 0, skipped, total: 0, msg: 'All subscribers already have grants' };
428
+ return;
429
+ }
430
+
431
+ const totalBatches = Math.ceil(needGrant.length / batchSize);
432
+ let granted = 0;
433
+ const errors = [];
434
+
435
+ for (let i = 0; i < needGrant.length; i += batchSize) {
436
+ if (isCancelled()) break;
437
+
438
+ const batchNum = Math.floor(i / batchSize) + 1;
439
+ const batch = needGrant.slice(i, i + batchSize);
440
+ const shortAddrs = batch.map(a => a.slice(0, 12) + '...' + a.slice(-6)).join(', ');
441
+
442
+ yield { type: 'batch_start', batch: batchNum, total: totalBatches, count: batch.length, addresses: shortAddrs };
443
+
444
+ const msgs = batch.map(grantee => buildFeeGrantMsg(granterAddress, grantee, grantOpts));
445
+
446
+ const t0 = Date.now();
447
+ try {
448
+ const result = await broadcast(msgs, `Fee grant batch ${batchNum}/${totalBatches}`);
449
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
450
+
451
+ if (result.code !== 0) {
452
+ const errMsg = result.rawLog || `TX failed code=${result.code}`;
453
+ yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: errMsg, elapsed };
454
+ errors.push(`Batch ${batchNum}: ${errMsg}`);
455
+ } else {
456
+ granted += batch.length;
457
+ yield {
458
+ type: 'batch_ok',
459
+ batch: batchNum,
460
+ total: totalBatches,
461
+ granted: batch.length,
462
+ totalGranted: granted,
463
+ txHash: result.transactionHash,
464
+ elapsed,
465
+ };
466
+ }
467
+ } catch (e) {
468
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
469
+ yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: e.message, elapsed };
470
+ errors.push(`Batch ${batchNum}: ${e.message}`);
471
+ }
472
+ }
473
+
474
+ yield {
475
+ type: 'done',
476
+ granted,
477
+ skipped,
478
+ total: needGrant.length,
479
+ errors: errors.length ? errors : undefined,
480
+ };
481
+ } catch (e) {
482
+ yield { type: 'error', msg: e.message };
483
+ }
484
+ }
485
+
486
+ // ─── Gas Cost Analytics ──────────────────────────────────────────────────────
487
+
488
+ /**
489
+ * Compute how many udvpn the granter has spent on fee-granted transactions
490
+ * for a plan's subscribers. Iterates each subscriber, pulls their outgoing
491
+ * TXs via LCD, and sums fees where `fee.granter === granterAddress`.
492
+ *
493
+ * @param {number|string} planId
494
+ * @param {object} opts
495
+ * @param {string} opts.granterAddress - Address that paid the fees (plan owner)
496
+ * @param {string} opts.lcdUrl - LCD endpoint
497
+ * @param {number} [opts.txLimit=100] - Max TXs to inspect per subscriber
498
+ * @param {(info: {processed:number, total:number, address:string}) => void} [opts.onProgress]
499
+ * @returns {Promise<{ totalUdvpn: number, txCount: number, byAddress: Record<string, {udvpn:number, txCount:number}>, subscriberCount: number }>}
500
+ */
501
+ export async function computeFeeGrantGasCosts(planId, opts = {}) {
502
+ const { granterAddress, lcdUrl, txLimit = 100, onProgress } = opts;
503
+ if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
504
+
505
+ const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
506
+ const subscriberAddrs = [...new Set(subscribers.map(s => s.acc_address || s.address))]
507
+ .filter(a => a && a !== granterAddress);
508
+
509
+ if (subscriberAddrs.length === 0) {
510
+ return { totalUdvpn: 0, txCount: 0, byAddress: {}, subscriberCount: 0 };
511
+ }
512
+
513
+ let totalUdvpn = 0;
514
+ let txCount = 0;
515
+ const byAddress = {};
516
+
517
+ const base = lcdUrl || LCD_ENDPOINTS[0].url;
518
+ for (let idx = 0; idx < subscriberAddrs.length; idx++) {
519
+ const addr = subscriberAddrs[idx];
520
+ try {
521
+ const path =
522
+ `/cosmos/tx/v1beta1/txs?events=${encodeURIComponent("message.sender='" + addr + "'")}` +
523
+ `&pagination.limit=${txLimit}&order_by=2`;
524
+ const txData = await lcdQuery(path, { lcdUrl: base });
525
+ const rawTxs = txData.txs || [];
526
+
527
+ let addrGas = 0;
528
+ let addrTxCount = 0;
529
+
530
+ for (const tx of rawTxs) {
531
+ const fee = tx?.auth_info?.fee;
532
+ if (fee?.granter === granterAddress) {
533
+ const udvpnFee = (fee.amount || []).find(f => f.denom === 'udvpn');
534
+ if (udvpnFee) {
535
+ addrGas += parseInt(udvpnFee.amount, 10);
536
+ addrTxCount++;
537
+ }
538
+ }
539
+ }
540
+
541
+ if (addrTxCount > 0) {
542
+ byAddress[addr] = { udvpn: addrGas, txCount: addrTxCount };
543
+ totalUdvpn += addrGas;
544
+ txCount += addrTxCount;
545
+ }
546
+ } catch { /* skip this subscriber on LCD failure */ }
547
+
548
+ if (onProgress) onProgress({ processed: idx + 1, total: subscriberAddrs.length, address: addr });
549
+ }
550
+
551
+ return { totalUdvpn, txCount, byAddress, subscriberCount: subscriberAddrs.length };
552
+ }
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));
115
132
  }
116
- return null;
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
+ }
144
+ }
145
+
146
+ return matching[0];
117
147
  }
118
148
 
119
149
  /**
@@ -841,3 +871,75 @@ export function saveVpnSettings(settings) {
841
871
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
842
872
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
843
873
  }
874
+
875
+ // ─── TX Hash Lookup (RPC-first, LCD fallback) ───────────────────────────────
876
+
877
+ /**
878
+ * Fetch a transaction by hash. RPC is tried first; if it fails or the TX is
879
+ * not found, falls back to the LCD REST endpoint.
880
+ *
881
+ * Accepts bare hex or 0x-prefixed hex for the hash.
882
+ * Returns the same normalised shape regardless of source:
883
+ * { hash, height, code, rawLog, events, gasUsed, gasWanted }
884
+ *
885
+ * Use this to re-fetch TX events after a crash/restart or from a different
886
+ * process (CosmJS only returns DeliverTxResponse inline from signAndBroadcast).
887
+ *
888
+ * @param {string} txHash - Transaction hash (bare hex or 0x-prefixed)
889
+ * @param {object} [opts]
890
+ * @param {string} [opts.rpcUrl] - RPC endpoint (uses cached client if omitted)
891
+ * @param {string} [opts.lcdUrl] - LCD endpoint for fallback
892
+ * @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>}
893
+ */
894
+ export async function getTxByHash(txHash, opts = {}) {
895
+ const hex = txHash.replace(/^0x/i, '').toUpperCase();
896
+
897
+ // ── RPC-first ──────────────────────────────────────────────────────────────
898
+ try {
899
+ let rpc;
900
+ if (opts.rpcUrl) {
901
+ const { createRpcQueryClient } = await import('./rpc.js');
902
+ rpc = await createRpcQueryClient(opts.rpcUrl);
903
+ } else {
904
+ rpc = await getRpcClient();
905
+ }
906
+ if (rpc?.tmClient) {
907
+ const result = await rpcGetTxByHash(rpc.tmClient, hex);
908
+ return result;
909
+ }
910
+ } catch (rpcErr) {
911
+ // "tx not found" from RPC → fall through to LCD
912
+ const msg = rpcErr?.message || '';
913
+ if (!msg.toLowerCase().includes('not found') && !msg.toLowerCase().includes('404')) {
914
+ // Real connectivity error — still fall through, LCD may succeed
915
+ }
916
+ }
917
+
918
+ // ── LCD fallback ───────────────────────────────────────────────────────────
919
+ try {
920
+ const doLcd = async (baseUrl) => {
921
+ const data = await lcdQuery(`/cosmos/tx/v1beta1/txs/${hex}`, { lcdUrl: baseUrl });
922
+ const txResp = data?.tx_response;
923
+ if (!txResp) return null;
924
+ const events = (txResp.events || []).map(ev => ({
925
+ type: ev.type,
926
+ attributes: (ev.attributes || []).map(attr => ({
927
+ key: attr.key,
928
+ value: attr.value,
929
+ })),
930
+ }));
931
+ return {
932
+ hash: (txResp.txhash || hex).toUpperCase(),
933
+ height: parseInt(txResp.height || '0', 10),
934
+ code: txResp.code || 0,
935
+ rawLog: txResp.raw_log || '',
936
+ events,
937
+ gasUsed: String(txResp.gas_used || '0'),
938
+ gasWanted: String(txResp.gas_wanted || '0'),
939
+ };
940
+ };
941
+ if (opts.lcdUrl) return await doLcd(opts.lcdUrl);
942
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, doLcd, `getTxByHash ${hex}`);
943
+ return result;
944
+ } catch { return null; }
945
+ }
package/chain/rpc.js CHANGED
@@ -402,11 +402,17 @@ function decodeAllocation(fields) {
402
402
  /**
403
403
  * Query active nodes via RPC.
404
404
  *
405
+ * NOTE ON PAGINATION: Sentinel v3's `QueryNodes` truncates at the requested
406
+ * `limit` and does NOT emit `pagination.next_key`. A standard Cosmos
407
+ * "loop while next_key is non-empty" pattern terminates on the first call
408
+ * and silently loses data. Request above the chain's current hard ceiling
409
+ * (~1048 active nodes as of 2026-04). Default raised to 10000.
410
+ *
405
411
  * @param {{ queryClient: QueryClient }} client - From createRpcQueryClient()
406
412
  * @param {{ status?: number, limit?: number }} [opts]
407
413
  * @returns {Promise<Array<{ address: string, gigabyte_prices: Array, hourly_prices: Array, remote_addrs: string[], status: number }>>}
408
414
  */
409
- export async function rpcQueryNodes(client, { status = 1, limit = 500 } = {}) {
415
+ export async function rpcQueryNodes(client, { status = 1, limit = 10000 } = {}) {
410
416
  const path = '/sentinel.node.v3.QueryService/QueryNodes';
411
417
  const request = concat([
412
418
  encodeEnum(1, status), // status field
@@ -446,12 +452,18 @@ export async function rpcQueryNode(client, address) {
446
452
  /**
447
453
  * Query nodes linked to a plan via RPC.
448
454
  *
455
+ * NOTE ON PAGINATION: `QueryNodesForPlan` silently truncates at the requested
456
+ * `limit` with no `next_key`. Observed 2026-04: plan 36 has 803 active nodes
457
+ * but `limit=500` returns exactly 500 with no indication more exist. Default
458
+ * raised to 10000. If a plan grows beyond that, raise further — the chain's
459
+ * own ceiling is the effective limit.
460
+ *
449
461
  * @param {{ queryClient: QueryClient }} client
450
462
  * @param {number|bigint} planId
451
463
  * @param {{ status?: number, limit?: number }} [opts]
452
464
  * @returns {Promise<Array>}
453
465
  */
454
- export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit = 500 } = {}) {
466
+ export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit = 10000 } = {}) {
455
467
  const path = '/sentinel.node.v3.QueryService/QueryNodesForPlan';
456
468
  const request = concat([
457
469
  encodeUint64(1, planId), // id
@@ -891,6 +903,50 @@ export async function rpcQueryProvider(client, provAddress) {
891
903
  }
892
904
  }
893
905
 
906
+ // ─── TX Hash Lookup ─────────────────────────────────────────────────────────
907
+
908
+ /**
909
+ * Fetch a transaction by hash via Tendermint RPC.
910
+ * Accepts bare hex (64 chars) or 0x-prefixed hex. Returns a normalized shape
911
+ * matching the LCD cosmos/tx/v1beta1/txs/{hash} response so callers don't
912
+ * need to handle both formats.
913
+ *
914
+ * @param {import('@cosmjs/tendermint-rpc').Tendermint37Client} tmClient - From createRpcQueryClient().tmClient
915
+ * @param {string} txHash - TX hash as hex string (bare or 0x-prefixed)
916
+ * @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string }>}
917
+ * @throws {Error} If the transaction is not found or RPC call fails
918
+ */
919
+ export async function rpcGetTxByHash(tmClient, txHash) {
920
+ // Strip optional 0x prefix and normalise to upper-case for consistency
921
+ const hex = txHash.replace(/^0x/i, '').toUpperCase();
922
+ // Decode hex string → Uint8Array
923
+ const hashBytes = new Uint8Array(hex.length / 2);
924
+ for (let i = 0; i < hex.length; i += 2) {
925
+ hashBytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
926
+ }
927
+
928
+ const response = await tmClient.tx({ hash: hashBytes });
929
+
930
+ // Normalise events: TxData.events is already decoded by CosmJS
931
+ const events = (response.result.events || []).map(ev => ({
932
+ type: ev.type,
933
+ attributes: (ev.attributes || []).map(attr => ({
934
+ key: attr.key,
935
+ value: attr.value,
936
+ })),
937
+ }));
938
+
939
+ return {
940
+ hash: hex,
941
+ height: response.height,
942
+ code: response.result.code,
943
+ rawLog: response.result.log || '',
944
+ events,
945
+ gasUsed: String(response.result.gasUsed),
946
+ gasWanted: String(response.result.gasWanted),
947
+ };
948
+ }
949
+
894
950
  export async function rpcQueryBalance(client, address, denom = 'udvpn') {
895
951
  const path = '/cosmos.bank.v1beta1.Query/Balance';
896
952
  const request = concat([
package/client.js CHANGED
@@ -28,6 +28,7 @@ import { EventEmitter } from 'events';
28
28
  import {
29
29
  connectDirect, connectViaPlan, connectAuto, queryOnlineNodes,
30
30
  disconnect as sdkDisconnect, disconnectState,
31
+ disconnectAndEndSession as sdkDisconnectAndEndSession, disconnectStateAndEndSession,
31
32
  isConnected as sdkIsConnected, getStatus as sdkGetStatus,
32
33
  registerCleanupHandlers, setSystemProxy, clearSystemProxy,
33
34
  events as sdkEvents, ConnectionState,
@@ -125,13 +126,28 @@ export class SentinelClient extends EventEmitter {
125
126
  }
126
127
 
127
128
  /**
128
- * Disconnect current VPN tunnel.
129
+ * Soft disconnect tear down the tunnel, leave the on-chain session active.
130
+ *
131
+ * A subsequent connect() to the SAME node reuses the session (no new payment).
132
+ * Use for pause / temporary disconnect / network-change recovery.
133
+ * To settle the session and reclaim the deposit, use disconnectAndEndSession().
129
134
  */
130
135
  async disconnect() {
131
136
  await disconnectState(this._state);
132
137
  this._connection = null;
133
138
  }
134
139
 
140
+ /**
141
+ * Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
142
+ *
143
+ * Settles the session after the ~2h window and refunds the unused deposit.
144
+ * Use when the user is done with this node (switching permanently or wants refund).
145
+ */
146
+ async disconnectAndEndSession() {
147
+ await disconnectStateAndEndSession(this._state);
148
+ this._connection = null;
149
+ }
150
+
135
151
  /**
136
152
  * Check if a VPN tunnel is currently active.
137
153
  */
@@ -9,7 +9,7 @@ import {
9
9
  events, _defaultState, progress, checkAborted,
10
10
  warnIfNoCleanup, cachedCreateWallet, _recordMetric,
11
11
  broadcastWithInactiveRetry, getConnectLock, setConnectLock,
12
- getAbortConnect, setAbortConnect,
12
+ getAbortConnect, setAbortConnect, _endSessionOnChain,
13
13
  } from './state.js';
14
14
 
15
15
  import {
@@ -31,7 +31,7 @@ import {
31
31
  import { createNodeHttpsAgent } from '../tls-trust.js';
32
32
  import { disconnectWireGuard } from '../wireguard.js';
33
33
 
34
- import { disconnectState } from './disconnect.js';
34
+ import { disconnectState, disconnectStateAndEndSession } from './disconnect.js';
35
35
  import { queryOnlineNodes } from './discovery.js';
36
36
  import {
37
37
  recordNodeFailure, isCircuitOpen, configureCircuitBreaker,
@@ -79,8 +79,10 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
79
79
  { nodeAddress: state.connection?.nodeAddress });
80
80
  }
81
81
  const prev = state.connection;
82
- await disconnectState(state);
83
- if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Disconnected from ${prev?.nodeAddress || 'previous node'}`);
82
+ // Hard disconnect: user is actively connecting to a different node,
83
+ // so the old session should be settled and the deposit refunded.
84
+ await disconnectStateAndEndSession(state);
85
+ if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Ended session on ${prev?.nodeAddress || 'previous node'} — connecting to new node`);
84
86
  }
85
87
 
86
88
  const onProgress = opts.onProgress || null;
@@ -397,7 +399,17 @@ export async function connectDirect(opts) {
397
399
  if (!forceNewSession) {
398
400
  progress(onProgress, logFn, 'session', 'Checking for existing session...');
399
401
  checkAborted(signal);
400
- sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress);
402
+ sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress, {
403
+ // Dedup: if multiple active sessions exist for this node (stale duplicates from
404
+ // crashes or multi-client wallets), keep the highest-ID one. Silently cancel stale
405
+ // lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
406
+ onStaleDuplicate: (staleId) => {
407
+ logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
408
+ _endSessionOnChain(staleId, opts.mnemonic).catch(e => {
409
+ logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
410
+ });
411
+ },
412
+ });
401
413
  if (sessionId && isSessionPoisoned(String(sessionId))) {
402
414
  progress(onProgress, logFn, 'session', `Session ${sessionId} previously failed — skipping`);
403
415
  sessionId = null;