blue-js-sdk 2.3.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.
Files changed (47) hide show
  1. package/README.md +3 -3
  2. package/batch.js +2 -2
  3. package/chain/authz.js +1 -9
  4. package/chain/fee-grants.js +199 -3
  5. package/chain/index.js +36 -167
  6. package/chain/queries.js +118 -7
  7. package/chain/rpc.js +58 -2
  8. package/client/index.js +1 -3
  9. package/client.js +17 -1
  10. package/connection/connect.js +17 -5
  11. package/connection/disconnect.js +86 -10
  12. package/connection/discovery.js +11 -11
  13. package/connection/index.js +2 -0
  14. package/cosmjs-setup.js +30 -153
  15. package/defaults.js +1 -1
  16. package/index.js +5 -1
  17. package/node-connect.js +118 -25
  18. package/operator.js +2 -0
  19. package/package.json +2 -5
  20. package/pricing/index.js +3 -26
  21. package/types/index.d.ts +2 -2
  22. package/ai-path/ADMIN-ELEVATION.md +0 -116
  23. package/ai-path/AI-MANIFESTO.md +0 -185
  24. package/ai-path/BREAKING.md +0 -74
  25. package/ai-path/CHECKLIST.md +0 -619
  26. package/ai-path/CONNECTION-STEPS.md +0 -724
  27. package/ai-path/DECISION-TREE.md +0 -422
  28. package/ai-path/DEPENDENCIES.md +0 -459
  29. package/ai-path/E2E-FLOW.md +0 -1707
  30. package/ai-path/FAILURES.md +0 -410
  31. package/ai-path/GUIDE.md +0 -1315
  32. package/ai-path/README.md +0 -599
  33. package/ai-path/SPLIT-TUNNEL.md +0 -266
  34. package/ai-path/cli.js +0 -548
  35. package/ai-path/connect.js +0 -1028
  36. package/ai-path/discover.js +0 -178
  37. package/ai-path/environment.js +0 -266
  38. package/ai-path/errors.js +0 -86
  39. package/ai-path/examples/autonomous-agent.mjs +0 -220
  40. package/ai-path/examples/multi-region.mjs +0 -174
  41. package/ai-path/examples/one-shot.mjs +0 -31
  42. package/ai-path/index.js +0 -79
  43. package/ai-path/pricing.js +0 -137
  44. package/ai-path/recommend.js +0 -413
  45. package/ai-path/run-admin.vbs +0 -25
  46. package/ai-path/setup.js +0 -291
  47. package/ai-path/wallet.js +0 -137
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
@@ -56,6 +57,15 @@ async function getRpcClient() {
56
57
  return _rpcClientPromise;
57
58
  }
58
59
 
60
+ /**
61
+ * Clear the cached RPC query client. Called during process cleanup
62
+ * to ensure WebSocket connections are properly closed.
63
+ */
64
+ export function resetQueryRpcCache() {
65
+ _rpcClient = null;
66
+ _rpcClientPromise = null;
67
+ }
68
+
59
69
  // ─── Query Helpers ───────────────────────────────────────────────────────────
60
70
 
61
71
  /**
@@ -72,8 +82,22 @@ export async function getBalance(client, address) {
72
82
  * Find an existing active session for a wallet+node pair.
73
83
  * Returns session ID (BigInt) or null. Use this to avoid double-paying.
74
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>}
75
99
  */
76
- export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
100
+ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts = {}) {
77
101
  let sessions;
78
102
 
79
103
  // RPC-first: returns decoded, flat session objects
@@ -93,6 +117,8 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
93
117
  });
94
118
  }
95
119
 
120
+ // Collect all non-exhausted active sessions for this node
121
+ const matching = [];
96
122
  for (const s of sessions) {
97
123
  if ((s.node_address || s.node) !== nodeAddr) continue;
98
124
  // RPC returns status as number (1=active), LCD as string
@@ -102,9 +128,22 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
102
128
  if (acct && acct !== walletAddr) continue;
103
129
  const maxBytes = parseInt(s.max_bytes || '0');
104
130
  const used = parseInt(s.download_bytes || '0') + parseInt(s.upload_bytes || '0');
105
- 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
+ }
106
144
  }
107
- return null;
145
+
146
+ return matching[0];
108
147
  }
109
148
 
110
149
  /**
@@ -344,9 +383,9 @@ export async function querySessionById(lcdUrl, sessionId) {
344
383
  }
345
384
  } catch { /* fall through to LCD */ }
346
385
 
347
- // LCD fallback
386
+ // LCD fallback (with endpoint failover via lcdQuery)
348
387
  try {
349
- const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
388
+ const data = await lcdQuery(`/sentinel/session/v3/sessions/${sessionId}`, { lcdUrl });
350
389
  const raw = data?.session;
351
390
  if (!raw) return null;
352
391
  return flattenSession(raw);
@@ -372,9 +411,9 @@ export async function querySessionAllocation(lcdUrl, sessionId) {
372
411
  } catch { /* fall through */ }
373
412
 
374
413
  if (!s) {
375
- // LCD fallback
414
+ // LCD fallback (with endpoint failover via lcdQuery)
376
415
  try {
377
- const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
416
+ const data = await lcdQuery(`/sentinel/session/v3/sessions/${sessionId}`, { lcdUrl });
378
417
  s = data.session?.base_session || data.session || null;
379
418
  } catch { return null; }
380
419
  }
@@ -832,3 +871,75 @@ export function saveVpnSettings(settings) {
832
871
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
833
872
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
834
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/index.js CHANGED
@@ -33,9 +33,7 @@ import {
33
33
  events as sdkEvents, ConnectionState,
34
34
  } from '../connection/index.js';
35
35
  import {
36
- createWallet, privKeyFromMnemonic, createClient, broadcast,
37
- createSafeBroadcaster, getBalance, findExistingSession, fetchActiveNodes,
38
- discoverPlanIds, resolveNodeUrl, lcd, MSG_TYPES,
36
+ createWallet, createClient, getBalance,
39
37
  } from '../chain/index.js';
40
38
  import { nodeStatusV3 } from '../protocol/index.js';
41
39
  import { createNodeHttpsAgent, clearKnownNode, clearAllKnownNodes, getKnownNode } from '../security/index.js';
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;
@@ -28,13 +28,35 @@ import { queryNode, privKeyFromMnemonic } from '../cosmjs-setup.js';
28
28
  import { createNodeHttpsAgent } from '../tls-trust.js';
29
29
 
30
30
  // ─── Disconnect ──────────────────────────────────────────────────────────────
31
+ //
32
+ // TWO DISCONNECT PATHS — CHOOSE INTENT EXPLICITLY.
33
+ //
34
+ // Soft: disconnect() / disconnectState(state)
35
+ // - Tears down the local tunnel (WireGuard/V2Ray) and cleans up system state.
36
+ // - Leaves the on-chain session in status=1 (active).
37
+ // - Next connectDirect() to the SAME node reuses the session via
38
+ // findExistingSession — no new MsgStartSession TX, no new payment, bandwidth preserved.
39
+ // - Use when: user is pausing, network changed, closing the app temporarily.
40
+ //
41
+ // Hard: disconnectAndEndSession() / disconnectStateAndEndSession(state)
42
+ // - Tears down the tunnel AND broadcasts MsgCancelSession on chain.
43
+ // - Session moves status=1 → settling → refund after ~2h settlement window.
44
+ // - Use when: user is done with this node, switching nodes permanently,
45
+ // or wants the bandwidth deposit back.
46
+ //
47
+ // Internal: _disconnectInternal(state, { endSession })
48
+ // - Caller MUST pass endSession explicitly as true or false.
49
+ // - No default — forces intentional choice at every callsite.
31
50
 
32
51
  /**
33
- * Clean up all active tunnels and system proxy.
34
- * ALWAYS call this on exit — a stale WireGuard tunnel will kill your internet.
52
+ * Internal disconnect implementation. Caller must explicitly pass endSession.
53
+ * @param {object} state - ConnectionState instance
54
+ * @param {{ endSession: boolean }} opts
55
+ * endSession: true → broadcast MsgCancelSession (hard disconnect)
56
+ * endSession: false → preserve on-chain session for reuse (soft disconnect)
57
+ * @private
35
58
  */
36
- /** Disconnect a specific state instance (internal). */
37
- export async function disconnectState(state) {
59
+ async function _disconnectInternal(state, { endSession }) {
38
60
  // v30: Signal any running connectAuto() retry loop to abort, and release the
39
61
  // connection lock so the user can reconnect after disconnect completes.
40
62
  setAbortConnect(true);
@@ -65,11 +87,18 @@ export async function disconnectState(state) {
65
87
  try { disableDnsLeakPrevention(); } catch (e) { console.warn('[sentinel-sdk] DNS restore warning:', e.message); }
66
88
  }
67
89
 
68
- // End session on chain (best-effort, fire-and-forget — never blocks disconnect)
69
- if (prev?.sessionId && state._mnemonic) {
70
- _endSessionOnChain(prev.sessionId, state._mnemonic).catch(e => {
71
- console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
72
- });
90
+ if (endSession) {
91
+ // Hard disconnect: broadcast MsgCancelSession — session settles and refunds deposit.
92
+ if (prev?.sessionId && state._mnemonic) {
93
+ _endSessionOnChain(prev.sessionId, state._mnemonic).catch(e => {
94
+ console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
95
+ });
96
+ }
97
+ } else {
98
+ // Soft disconnect: leave session on chain in status=1 for reuse.
99
+ if (prev?.sessionId) {
100
+ console.log(`[sentinel-sdk] Session ${prev.sessionId} preserved on chain (status=1) for future reuse — no MsgCancelSession broadcast.`);
101
+ }
73
102
  }
74
103
  } finally {
75
104
  // ALWAYS clear connection state — even if teardown threw
@@ -82,8 +111,55 @@ export async function disconnectState(state) {
82
111
  }
83
112
  }
84
113
 
114
+ /**
115
+ * Soft disconnect — tear down the local tunnel, leave the on-chain session active.
116
+ *
117
+ * A subsequent connectDirect() to the SAME node will reuse the session via
118
+ * findExistingSession — no new MsgStartSession TX, no new payment, remaining
119
+ * bandwidth is preserved.
120
+ *
121
+ * Use when: user is pausing, network changed, or closing the app temporarily.
122
+ * To settle the session on-chain and reclaim the unused deposit, use
123
+ * disconnectAndEndSession() instead.
124
+ *
125
+ * @param {object} [state] - ConnectionState instance (defaults to _defaultState)
126
+ */
127
+ export async function disconnectState(state) {
128
+ return _disconnectInternal(state, { endSession: false });
129
+ }
130
+
131
+ /**
132
+ * Soft disconnect (default state) — tear down the tunnel, leave on-chain session active.
133
+ *
134
+ * @see disconnectState
135
+ */
85
136
  export async function disconnect() {
86
- return disconnectState(_defaultState);
137
+ return _disconnectInternal(_defaultState, { endSession: false });
138
+ }
139
+
140
+ /**
141
+ * Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
142
+ *
143
+ * The session settles after the ~2h inactive_pending window. The node refunds
144
+ * the unused portion of the bandwidth deposit (for peer-to-peer sessions).
145
+ * For plan-based sessions, this stops metering against the plan allocation.
146
+ *
147
+ * Use when: user is done with this node (switching nodes permanently, ending
148
+ * their session, or wants the deposit back).
149
+ *
150
+ * @param {object} [state] - ConnectionState instance (defaults to _defaultState)
151
+ */
152
+ export async function disconnectStateAndEndSession(state) {
153
+ return _disconnectInternal(state, { endSession: true });
154
+ }
155
+
156
+ /**
157
+ * Hard disconnect (default state) — tear down the tunnel AND broadcast MsgCancelSession.
158
+ *
159
+ * @see disconnectStateAndEndSession
160
+ */
161
+ export async function disconnectAndEndSession() {
162
+ return _disconnectInternal(_defaultState, { endSession: true });
87
163
  }
88
164
 
89
165
  // ─── Cleanup Registration ───────────────────────────────────────────────────
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Node Discovery — query, fetch, enrich, index, and score nodes.
3
3
  *
4
- * Handles LCD queries for online nodes, caching, quality scoring,
5
- * and geographic indexing.
4
+ * Handles RPC-first queries (LCD fallback) for online nodes, caching,
5
+ * quality scoring, and geographic indexing.
6
6
  */
7
7
 
8
8
  import {
@@ -63,7 +63,7 @@ export function scoreNode(status) {
63
63
  // ─── Query Nodes ─────────────────────────────────────────────────────────────
64
64
 
65
65
  /**
66
- * Fetch active nodes from LCD and check which are actually online.
66
+ * Fetch active nodes via RPC-first (LCD fallback) and check which are actually online.
67
67
  * Returns array sorted by quality score (best first).
68
68
  *
69
69
  * Built-in quality scoring (from 400+ node tests):
@@ -119,12 +119,12 @@ async function _queryOnlineNodesImpl(options = {}) {
119
119
  const logFn = options.log || null;
120
120
  const brokenAddrs = new Set(BROKEN_NODES.map(n => n.address));
121
121
 
122
- // 1. Fetch ALL active nodes from LCD uses lcdPaginatedSafe (handles broken pagination)
122
+ // 1. Fetch ALL active nodes via RPC-first (falls back to LCD if RPC fails)
123
123
  let nodes = [];
124
124
  if (options.lcdUrl) {
125
125
  nodes = await fetchActiveNodes(options.lcdUrl);
126
126
  } else {
127
- const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, 'LCD node list');
127
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, 'RPC-first node list');
128
128
  nodes = result;
129
129
  }
130
130
 
@@ -194,12 +194,12 @@ async function _queryOnlineNodesImpl(options = {}) {
194
194
  return online;
195
195
  }
196
196
 
197
- // ─── Full Node Catalog (LCD only, no per-node status checks) ────────────────
197
+ // ─── Full Node Catalog (RPC-first, no per-node status checks) ───────────────
198
198
 
199
199
  /**
200
- * Fetch ALL active nodes from the LCD. No per-node HTTP checks — instant.
200
+ * Fetch ALL active nodes via RPC-first (LCD fallback). No per-node HTTP checks — instant.
201
201
  *
202
- * Returns every node that accepts udvpn, with LCD data only:
202
+ * Returns every node that accepts udvpn, with chain data:
203
203
  * address, remote_url, gigabyte_prices, hourly_prices.
204
204
  *
205
205
  * Use this for: building node lists/maps, country pickers, price comparisons.
@@ -217,7 +217,7 @@ export async function fetchAllNodes(options = {}) {
217
217
  const { result } = await tryWithFallback(
218
218
  LCD_ENDPOINTS,
219
219
  async (url) => fetchActiveNodes(url),
220
- 'LCD full node list',
220
+ 'RPC-first full node list',
221
221
  );
222
222
  nodes = result;
223
223
  }
@@ -275,9 +275,9 @@ export function buildNodeIndex(nodes) {
275
275
  }
276
276
 
277
277
  /**
278
- * Enrich LCD nodes with type/country/city by probing each node's status API.
278
+ * Enrich chain nodes with type/country/city by probing each node's status API.
279
279
  *
280
- * @param {Array} nodes - Raw LCD nodes from fetchAllNodes()
280
+ * @param {Array} nodes - Raw chain nodes from fetchAllNodes()
281
281
  * @param {object} [options]
282
282
  * @param {number} [options.concurrency=30] - Parallel probes
283
283
  * @param {function} [options.onProgress] - Callback: ({ total, done, enriched }) => void
@@ -32,6 +32,8 @@ export {
32
32
  export {
33
33
  disconnect,
34
34
  disconnectState,
35
+ disconnectAndEndSession,
36
+ disconnectStateAndEndSession,
35
37
  registerCleanupHandlers,
36
38
  recoverSession,
37
39
  } from './disconnect.js';