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
@@ -26,15 +26,42 @@ import { DEFAULT_LCD, DEFAULT_TIMEOUTS } from '../defaults.js';
26
26
  import { nodeStatusV3 } from '../v3protocol.js';
27
27
  import { queryNode, privKeyFromMnemonic } from '../cosmjs-setup.js';
28
28
  import { createNodeHttpsAgent } from '../tls-trust.js';
29
+ import { withMnemonicRedaction } from './logger.js';
30
+
31
+ // Default logger wraps console.log so any 12–24 word BIP-39 phrase that ends
32
+ // up in a log argument is replaced before reaching stdout. See connection/logger.js.
33
+ const defaultLog = withMnemonicRedaction(console.log);
29
34
 
30
35
  // ─── Disconnect ──────────────────────────────────────────────────────────────
36
+ //
37
+ // TWO DISCONNECT PATHS — CHOOSE INTENT EXPLICITLY.
38
+ //
39
+ // Soft: disconnect() / disconnectState(state)
40
+ // - Tears down the local tunnel (WireGuard/V2Ray) and cleans up system state.
41
+ // - Leaves the on-chain session in status=1 (active).
42
+ // - Next connectDirect() to the SAME node reuses the session via
43
+ // findExistingSession — no new MsgStartSession TX, no new payment, bandwidth preserved.
44
+ // - Use when: user is pausing, network changed, closing the app temporarily.
45
+ //
46
+ // Hard: disconnectAndEndSession() / disconnectStateAndEndSession(state)
47
+ // - Tears down the tunnel AND broadcasts MsgCancelSession on chain.
48
+ // - Session moves status=1 → settling → refund after ~2h settlement window.
49
+ // - Use when: user is done with this node, switching nodes permanently,
50
+ // or wants the bandwidth deposit back.
51
+ //
52
+ // Internal: _disconnectInternal(state, { endSession })
53
+ // - Caller MUST pass endSession explicitly as true or false.
54
+ // - No default — forces intentional choice at every callsite.
31
55
 
32
56
  /**
33
- * Clean up all active tunnels and system proxy.
34
- * ALWAYS call this on exit — a stale WireGuard tunnel will kill your internet.
57
+ * Internal disconnect implementation. Caller must explicitly pass endSession.
58
+ * @param {object} state - ConnectionState instance
59
+ * @param {{ endSession: boolean }} opts
60
+ * endSession: true → broadcast MsgCancelSession (hard disconnect)
61
+ * endSession: false → preserve on-chain session for reuse (soft disconnect)
62
+ * @private
35
63
  */
36
- /** Disconnect a specific state instance (internal). */
37
- export async function disconnectState(state) {
64
+ async function _disconnectInternal(state, { endSession }) {
38
65
  // v30: Signal any running connectAuto() retry loop to abort, and release the
39
66
  // connection lock so the user can reconnect after disconnect completes.
40
67
  setAbortConnect(true);
@@ -65,15 +92,22 @@ export async function disconnectState(state) {
65
92
  try { disableDnsLeakPrevention(); } catch (e) { console.warn('[sentinel-sdk] DNS restore warning:', e.message); }
66
93
  }
67
94
 
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
- });
95
+ if (endSession) {
96
+ // Hard disconnect: broadcast MsgCancelSession — session settles and refunds deposit.
97
+ if (prev?.sessionId && state._wallet) {
98
+ _endSessionOnChain(prev.sessionId, state._wallet).catch(e => {
99
+ console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
100
+ });
101
+ }
102
+ } else {
103
+ // Soft disconnect: leave session on chain in status=1 for reuse.
104
+ if (prev?.sessionId) {
105
+ console.log(`[sentinel-sdk] Session ${prev.sessionId} preserved on chain (status=1) for future reuse — no MsgCancelSession broadcast.`);
106
+ }
73
107
  }
74
108
  } finally {
75
109
  // ALWAYS clear connection state — even if teardown threw
76
- state._mnemonic = null;
110
+ state._wallet = null;
77
111
  state.connection = null;
78
112
  clearState();
79
113
  clearWalletCache(); // v34: Clear cached wallet objects (private keys) from memory
@@ -82,8 +116,55 @@ export async function disconnectState(state) {
82
116
  }
83
117
  }
84
118
 
119
+ /**
120
+ * Soft disconnect — tear down the local tunnel, leave the on-chain session active.
121
+ *
122
+ * A subsequent connectDirect() to the SAME node will reuse the session via
123
+ * findExistingSession — no new MsgStartSession TX, no new payment, remaining
124
+ * bandwidth is preserved.
125
+ *
126
+ * Use when: user is pausing, network changed, or closing the app temporarily.
127
+ * To settle the session on-chain and reclaim the unused deposit, use
128
+ * disconnectAndEndSession() instead.
129
+ *
130
+ * @param {object} [state] - ConnectionState instance (defaults to _defaultState)
131
+ */
132
+ export async function disconnectState(state) {
133
+ return _disconnectInternal(state, { endSession: false });
134
+ }
135
+
136
+ /**
137
+ * Soft disconnect (default state) — tear down the tunnel, leave on-chain session active.
138
+ *
139
+ * @see disconnectState
140
+ */
85
141
  export async function disconnect() {
86
- return disconnectState(_defaultState);
142
+ return _disconnectInternal(_defaultState, { endSession: false });
143
+ }
144
+
145
+ /**
146
+ * Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
147
+ *
148
+ * The session settles after the ~2h inactive_pending window. The node refunds
149
+ * the unused portion of the bandwidth deposit (for peer-to-peer sessions).
150
+ * For plan-based sessions, this stops metering against the plan allocation.
151
+ *
152
+ * Use when: user is done with this node (switching nodes permanently, ending
153
+ * their session, or wants the deposit back).
154
+ *
155
+ * @param {object} [state] - ConnectionState instance (defaults to _defaultState)
156
+ */
157
+ export async function disconnectStateAndEndSession(state) {
158
+ return _disconnectInternal(state, { endSession: true });
159
+ }
160
+
161
+ /**
162
+ * Hard disconnect (default state) — tear down the tunnel AND broadcast MsgCancelSession.
163
+ *
164
+ * @see disconnectStateAndEndSession
165
+ */
166
+ export async function disconnectAndEndSession() {
167
+ return _disconnectInternal(_defaultState, { endSession: true });
87
168
  }
88
169
 
89
170
  // ─── Cleanup Registration ───────────────────────────────────────────────────
@@ -128,7 +209,7 @@ export async function recoverSession(opts) {
128
209
  if (!opts?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'recoverSession requires opts.nodeAddress');
129
210
  if (!opts?.mnemonic) throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'recoverSession requires opts.mnemonic');
130
211
 
131
- const logFn = opts.log || console.log;
212
+ const logFn = opts.log || defaultLog;
132
213
  const onProgress = opts.onProgress || null;
133
214
  const sessionId = BigInt(opts.sessionId);
134
215
  const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };
@@ -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';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Redacting logger wrapper — defense-in-depth against accidental mnemonic leaks
3
+ * in SDK log output.
4
+ *
5
+ * The SDK's default logger is `console.log`. Any future bug that interpolates
6
+ * a mnemonic into a log template string (e.g. `log(`opts: ${JSON.stringify(opts)}`)`)
7
+ * would leak the BIP-39 phrase to stdout/stderr — and from there to terminal
8
+ * scrollback, CI logs, log-aggregation tools (Datadog, Loki, Sentry breadcrumbs),
9
+ * and shell history.
10
+ *
11
+ * This module wraps any logger function with a regex-based redactor that matches
12
+ * BIP-39 word sequences and replaces them with `[REDACTED MNEMONIC]` before the
13
+ * underlying logger sees them. It is NOT a substitute for the rule "do not log
14
+ * the mnemonic" — it is a safety net so that violation does not produce a leak.
15
+ *
16
+ * Performance: the regex runs only on string arguments and short-circuits if the
17
+ * argument has fewer than ~60 characters (a 12-word mnemonic is ~80 characters).
18
+ * Negligible overhead on the SDK's hot path (a few connect-time progress logs).
19
+ */
20
+
21
+ /**
22
+ * Match 12 / 15 / 18 / 21 / 24 lowercase BIP-39-shaped words separated by single
23
+ * spaces. We deliberately don't try to validate against the full 2048-word list
24
+ * here — the goal is to catch anything that *looks* like a mnemonic and redact
25
+ * it. False positives (e.g. a long lowercase sentence) are acceptable since the
26
+ * SDK's own log strings never contain 12+ consecutive lowercase-only ASCII words.
27
+ *
28
+ * BIP-39 words are 3–8 lowercase ASCII letters (a–z), no digits, no diacritics.
29
+ */
30
+ const MNEMONIC_REGEX = /\b(?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/g;
31
+
32
+ const REDACTED = '[REDACTED MNEMONIC]';
33
+
34
+ /**
35
+ * Redact mnemonic-shaped substrings from a single value. Strings are scanned;
36
+ * everything else is returned unchanged. We do NOT recurse into objects — the
37
+ * SDK's loggers are called with scalar args, and walking arbitrary objects
38
+ * would risk triggering custom getters that have side effects.
39
+ *
40
+ * @param {*} value
41
+ * @returns {*}
42
+ */
43
+ function redactValue(value) {
44
+ if (typeof value !== 'string') return value;
45
+ if (value.length < 60) return value; // shortest plausible 12-word phrase ~ 60 chars
46
+ return value.replace(MNEMONIC_REGEX, REDACTED);
47
+ }
48
+
49
+ /**
50
+ * Wrap a logger function so that mnemonic-shaped strings in its arguments are
51
+ * replaced with `[REDACTED MNEMONIC]` before they reach the wrapped logger.
52
+ * Pass-through for non-function input (returns it unchanged) so callers can
53
+ * disable logging by passing `null`.
54
+ *
55
+ * @param {Function|null|undefined} logFn - underlying logger (typically console.log)
56
+ * @returns {Function|null|undefined}
57
+ */
58
+ export function withMnemonicRedaction(logFn) {
59
+ if (typeof logFn !== 'function') return logFn;
60
+ return function redactedLog(...args) {
61
+ return logFn(...args.map(redactValue));
62
+ };
63
+ }
64
+
65
+ // Exported for tests.
66
+ export const _internal = { MNEMONIC_REGEX, redactValue };
@@ -30,6 +30,11 @@ import { findV2RayExe } from './tunnel.js';
30
30
  import { enableKillSwitch, isKillSwitchEnabled as _isKillSwitchEnabled } from './security.js';
31
31
  import { setSystemProxy, clearSystemProxy, checkPortFree } from './proxy.js';
32
32
  import { connectAuto, connectViaSubscription, connectViaPlan } from './connect.js';
33
+ import { withMnemonicRedaction } from './logger.js';
34
+
35
+ // Default logger wraps console.log so any 12–24 word BIP-39 phrase that ends
36
+ // up in a log argument is replaced before reaching stdout. See connection/logger.js.
37
+ const defaultLog = withMnemonicRedaction(console.log);
33
38
 
34
39
  // ─── Circuit Breaker ─────────────────────────────────────────────────────────
35
40
  // v22: Skip nodes that repeatedly fail. Resets after TTL expires.
@@ -108,7 +113,7 @@ export async function tryFastReconnect(opts, state = _defaultState) {
108
113
  if (!saved) return null;
109
114
 
110
115
  const onProgress = opts.onProgress || null;
111
- const logFn = opts.log || console.log;
116
+ const logFn = opts.log || defaultLog;
112
117
  const fullTunnel = opts.fullTunnel !== false;
113
118
  const killSwitch = opts.killSwitch === true;
114
119
  const systemProxy = opts.systemProxy === true;
@@ -210,12 +215,12 @@ export async function tryFastReconnect(opts, state = _defaultState) {
210
215
  }
211
216
  try { await disconnectWireGuard(); } catch {}
212
217
  // End session on chain (fire-and-forget)
213
- if (saved.sessionId && state._mnemonic) {
214
- _endSessionOnChain(saved.sessionId, state._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
218
+ if (saved.sessionId && state._wallet) {
219
+ _endSessionOnChain(saved.sessionId, state._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
215
220
  }
216
221
  state.wgTunnel = null;
217
222
  state.connection = null;
218
- state._mnemonic = null;
223
+ state._wallet = null;
219
224
  clearState();
220
225
  },
221
226
  };
@@ -335,11 +340,11 @@ export async function tryFastReconnect(opts, state = _defaultState) {
335
340
  if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
336
341
  if (state.systemProxy) clearSystemProxy(state);
337
342
  // End session on chain (fire-and-forget)
338
- if (sessionIdStr && state._mnemonic) {
339
- _endSessionOnChain(sessionIdStr, state._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
343
+ if (sessionIdStr && state._wallet) {
344
+ _endSessionOnChain(sessionIdStr, state._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
340
345
  }
341
346
  state.connection = null;
342
- state._mnemonic = null;
347
+ state._wallet = null;
343
348
  clearState();
344
349
  },
345
350
  };
@@ -82,7 +82,13 @@ export class ConnectionState {
82
82
  this.systemProxy = false;
83
83
  this.connection = null; // { nodeAddress, serviceType, sessionId, connectedAt, socksPort? }
84
84
  this.savedProxyState = null;
85
- this._mnemonic = null; // Stored for session-end TX on disconnect (zeroed after use)
85
+ // v37 (security): store the derived OfflineSigner wallet, NOT the raw BIP-39 mnemonic.
86
+ // Previously _mnemonic held the recovery phrase for the full session lifetime so
87
+ // that disconnect could sign MsgEndSession. The mnemonic is the highest-value
88
+ // secret in the SDK — keeping it on a long-lived object enlarged the heap-dump
89
+ // attack surface unnecessarily. The wallet object holds only the derived signing
90
+ // key, which is no more sensitive than the privKey buffer we were already keeping.
91
+ this._wallet = null; // OfflineSigner — used by _endSessionOnChain on disconnect
86
92
  _activeStates.add(this);
87
93
  }
88
94
  get isConnected() { return !!(this.v2rayProc || this.wgTunnel); }
@@ -216,11 +222,14 @@ export function formatUptime(ms) {
216
222
  * End a session on-chain. Best-effort, fire-and-forget.
217
223
  * Prevents stale session accumulation on nodes.
218
224
  * @param {string|bigint} sessionId - Session ID to end
219
- * @param {string} mnemonic - BIP39 mnemonic for signing the TX
225
+ * @param {object} walletObj - { wallet, account } from cachedCreateWallet (NOT the mnemonic).
226
+ * Accepting the derived signer instead of the raw phrase keeps the BIP-39 mnemonic
227
+ * off the long-lived ConnectionState — see ConnectionState._wallet.
220
228
  * @private
221
229
  */
222
- export async function _endSessionOnChain(sessionId, mnemonic) {
223
- const { wallet, account } = await cachedCreateWallet(mnemonic);
230
+ export async function _endSessionOnChain(sessionId, walletObj) {
231
+ if (!walletObj) throw new SentinelError(ErrorCodes.INVALID_OPTIONS, '_endSessionOnChain requires a wallet object');
232
+ const { wallet, account } = walletObj;
224
233
  const client = await tryWithFallback(
225
234
  RPC_ENDPOINTS,
226
235
  async (url) => createClient(url, wallet),
@@ -261,8 +270,8 @@ export function getStatus() {
261
270
  const stale = _defaultState.connection;
262
271
  _defaultState.connection = null;
263
272
  // End session on chain (fire-and-forget) to prevent stale session leaks
264
- if (stale?.sessionId && _defaultState._mnemonic) {
265
- _endSessionOnChain(stale.sessionId, _defaultState._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
273
+ if (stale?.sessionId && _defaultState._wallet) {
274
+ _endSessionOnChain(stale.sessionId, _defaultState._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
266
275
  }
267
276
  clearState();
268
277
  events.emit('disconnected', { nodeAddress: stale.nodeAddress, serviceType: stale.serviceType, reason: 'phantom_state' });
@@ -308,8 +317,8 @@ export function getStatus() {
308
317
  // clean up stale state. Prevents ghost "connected" status after tunnel dies.
309
318
  if (!healthChecks.tunnelActive && !_defaultState.v2rayProc && !_defaultState.wgTunnel) {
310
319
  // Both tunnel handles are null — connection state is stale
311
- if (conn?.sessionId && _defaultState._mnemonic) {
312
- _endSessionOnChain(conn.sessionId, _defaultState._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
320
+ if (conn?.sessionId && _defaultState._wallet) {
321
+ _endSessionOnChain(conn.sessionId, _defaultState._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
313
322
  }
314
323
  _defaultState.connection = null;
315
324
  clearState();
@@ -317,8 +326,8 @@ export function getStatus() {
317
326
  }
318
327
  if (_defaultState.wgTunnel && !healthChecks.tunnelActive) {
319
328
  // WireGuard state says connected but tunnel is dead — auto-cleanup
320
- if (conn?.sessionId && _defaultState._mnemonic) {
321
- _endSessionOnChain(conn.sessionId, _defaultState._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
329
+ if (conn?.sessionId && _defaultState._wallet) {
330
+ _endSessionOnChain(conn.sessionId, _defaultState._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
322
331
  }
323
332
  _defaultState.wgTunnel = null;
324
333
  _defaultState.connection = null;
@@ -328,8 +337,8 @@ export function getStatus() {
328
337
  }
329
338
  if (_defaultState.v2rayProc && !healthChecks.tunnelActive) {
330
339
  // V2Ray process died — auto-cleanup
331
- if (conn?.sessionId && _defaultState._mnemonic) {
332
- _endSessionOnChain(conn.sessionId, _defaultState._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
340
+ if (conn?.sessionId && _defaultState._wallet) {
341
+ _endSessionOnChain(conn.sessionId, _defaultState._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
333
342
  }
334
343
  _defaultState.v2rayProc = null;
335
344
  _defaultState.connection = null;
@@ -389,12 +389,12 @@ async function setupWireGuard({ remoteUrl, sessionId, privKey, fullTunnel, split
389
389
  if (isKillSwitchEnabled()) disableKillSwitch();
390
390
  try { await disconnectWireGuard(); } catch {} // tunnel may already be down
391
391
  // End session on chain (fire-and-forget)
392
- if (sessionIdStr && state._mnemonic) {
393
- _endSessionOnChain(sessionIdStr, state._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
392
+ if (sessionIdStr && state._wallet) {
393
+ _endSessionOnChain(sessionIdStr, state._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
394
394
  }
395
395
  state.wgTunnel = null;
396
396
  state.connection = null;
397
- state._mnemonic = null;
397
+ state._wallet = null;
398
398
  clearState();
399
399
  },
400
400
  };
@@ -560,12 +560,28 @@ async function setupV2Ray({ remoteUrl, serverHost, sessionId, privKey, v2rayExeP
560
560
  }
561
561
  });
562
562
  }
563
- // Delete config after V2Ray reads it (contains UUID credentials)
564
- setTimeout(() => { try { unlinkSync(cfgPath); } catch {} }, 2000);
563
+ // Delete config after V2Ray reads it (contains UUID credentials).
564
+ // Race-safe approach: delete the moment we know V2Ray has loaded config
565
+ // (port is up OR process exited), and a deadline-bound unref'd safety net.
566
+ // The previous fixed 2s timer raced the loop iterating to the next outbound,
567
+ // which overwrites cfgPath — the stale timer would then delete the NEW config.
568
+ let cfgDeleted = false;
569
+ const deleteCfg = () => {
570
+ if (cfgDeleted) return;
571
+ cfgDeleted = true;
572
+ try { unlinkSync(cfgPath); } catch {} // best-effort: V2Ray may have file open on Windows
573
+ };
574
+ proc.once('exit', deleteCfg);
575
+ const cfgSafetyTimer = setTimeout(deleteCfg, 5000);
576
+ cfgSafetyTimer.unref();
565
577
 
566
578
  // Wait for SOCKS5 port to accept connections instead of fixed sleep.
567
579
  // V2Ray binding is async — fixed 6s sleep causes false failures on slow starts.
568
580
  const ready = await waitForPort(socksPort, timeouts.v2rayReady);
581
+ // Once the SOCKS port accepts connections, V2Ray has fully parsed the config —
582
+ // safe to delete (Windows: file is still open by V2Ray, unlink will return EBUSY,
583
+ // try/catch swallows; falls back to exit-handler or process orphan-cleanup).
584
+ if (ready) deleteCfg();
569
585
  if (!ready || proc.exitCode !== null) {
570
586
  progress(onProgress, logFn, 'tunnel', ` ${ob.tag}: v2ray ${proc.exitCode !== null ? `exited (code ${proc.exitCode})` : 'SOCKS5 port not ready'}, skipping`);
571
587
  proc.kill();
@@ -653,11 +669,11 @@ async function setupV2Ray({ remoteUrl, serverHost, sessionId, privKey, v2rayExeP
653
669
  if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
654
670
  if (state.systemProxy) clearSystemProxy();
655
671
  // End session on chain (fire-and-forget)
656
- if (sessionIdStr && state._mnemonic) {
657
- _endSessionOnChain(sessionIdStr, state._mnemonic).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
672
+ if (sessionIdStr && state._wallet) {
673
+ _endSessionOnChain(sessionIdStr, state._wallet).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
658
674
  }
659
675
  state.connection = null;
660
- state._mnemonic = null;
676
+ state._wallet = null;
661
677
  clearState();
662
678
  },
663
679
  };
package/cosmjs-setup.js CHANGED
@@ -74,6 +74,8 @@ import {
74
74
  getProviderByAddress as _getProviderByAddress,
75
75
  queryPlanSubscribers as _queryPlanSubscribers,
76
76
  getPlanStats as _getPlanStats,
77
+ queryPlanDetails as _queryPlanDetails,
78
+ isActiveStatus as _isActiveStatus,
77
79
  querySubscriptionAllocations as _querySubscriptionAllocations,
78
80
  queryAuthzGrants as _queryAuthzGrants,
79
81
  loadVpnSettings as _loadVpnSettings,
@@ -83,10 +85,13 @@ import {
83
85
  queryFeeGrants as _queryFeeGrants,
84
86
  queryFeeGrantsIssued as _queryFeeGrantsIssued,
85
87
  queryFeeGrant as _queryFeeGrant,
88
+ checkFeeGrant as _checkFeeGrant,
86
89
  grantPlanSubscribers as _grantPlanSubscribers,
87
90
  getExpiringGrants as _getExpiringGrants,
88
91
  renewExpiringGrants as _renewExpiringGrants,
89
92
  monitorFeeGrants as _monitorFeeGrants,
93
+ streamGrantPlanSubscribers as _streamGrantPlanSubscribers,
94
+ computeFeeGrantGasCosts as _computeFeeGrantGasCosts,
90
95
  } from './chain/fee-grants.js';
91
96
 
92
97
  // ─── Input Validation Helpers ────────────────────────────────────────────────
@@ -485,9 +490,15 @@ export async function getBalance(client, address) {
485
490
  * Returns session ID (BigInt) or null. Use this to avoid double-paying.
486
491
  *
487
492
  * Note: Sessions have a nested base_session object containing the actual data.
493
+ *
494
+ * @param {string} lcdUrl - LCD endpoint URL
495
+ * @param {string} walletAddr - sent1... wallet address
496
+ * @param {string} nodeAddr - sentnode1... node address
497
+ * @param {object} [opts] - Optional. Pass `{ onStaleDuplicate: (BigInt) => void }` to
498
+ * receive fire-and-forget cancellation callbacks for stale duplicate sessions.
488
499
  */
489
- export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
490
- return _findExistingSession(lcdUrl, walletAddr, nodeAddr);
500
+ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts) {
501
+ return _findExistingSession(lcdUrl, walletAddr, nodeAddr, opts);
491
502
  }
492
503
 
493
504
  /**
@@ -804,6 +815,20 @@ export async function queryFeeGrant(lcdUrl, granter, grantee) {
804
815
  return _queryFeeGrant(lcdUrl, granter, grantee);
805
816
  }
806
817
 
818
+ /**
819
+ * Builder-friendly parsed fee-grant status — exists, expired, expiresAt,
820
+ * spendLimit, allowedMessages. RPC-first with LCD fallback. Use before
821
+ * broadcasting plan/subscription session TXs that rely on a fee granter.
822
+ *
823
+ * @param {string} lcdUrl
824
+ * @param {string} granter
825
+ * @param {string} grantee
826
+ * @returns {Promise<{ exists: boolean, expired: boolean, expiresAt: Date|null, spendLimit: Array<{denom:string,amount:string}>, allowedMessages: string[]|null, typeUrl: string, raw: object|null }>}
827
+ */
828
+ export async function checkFeeGrant(lcdUrl, granter, grantee) {
829
+ return _checkFeeGrant(lcdUrl, granter, grantee);
830
+ }
831
+
807
832
  /**
808
833
  * Broadcast a TX with fee paid by a granter (fee grant).
809
834
  * The grantee signs; the granter pays gas via their fee allowance.
@@ -945,6 +970,29 @@ export async function getPlanStats(planId, ownerAddress, opts = {}) {
945
970
  return _getPlanStats(planId, ownerAddress, opts);
946
971
  }
947
972
 
973
+ /**
974
+ * Query a plan's on-chain metadata (provider, prices, duration, status).
975
+ * RPC-first with LCD fallback. Returns null if the plan does not exist.
976
+ *
977
+ * @param {number|string} planId
978
+ * @param {object} [opts]
979
+ * @param {string} [opts.lcdUrl]
980
+ * @returns {Promise<{ planId: string, provider: string, prices: Array, bytes: string, duration: string, status: number, statusAt: string|null, private: boolean } | null>}
981
+ */
982
+ export async function queryPlanDetails(planId, opts = {}) {
983
+ return _queryPlanDetails(planId, opts);
984
+ }
985
+
986
+ /**
987
+ * Normalize chain status values. Returns true only for active status
988
+ * (never for transient INACTIVE_PENDING / status=3).
989
+ * @param {number|string|undefined} v
990
+ * @returns {boolean}
991
+ */
992
+ export function isActiveStatus(v) {
993
+ return _isActiveStatus(v);
994
+ }
995
+
948
996
  // ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
949
997
 
950
998
  /**
@@ -1006,6 +1054,22 @@ export function monitorFeeGrants(opts = {}) {
1006
1054
  return _monitorFeeGrants(opts);
1007
1055
  }
1008
1056
 
1057
+ /**
1058
+ * Stream progress as we grant fee allowances to all plan subscribers in batches.
1059
+ * Async generator. See chain/fee-grants.js for event shapes.
1060
+ */
1061
+ export function streamGrantPlanSubscribers(planId, opts = {}) {
1062
+ return _streamGrantPlanSubscribers(planId, opts);
1063
+ }
1064
+
1065
+ /**
1066
+ * Sum udvpn fees the granter has paid on behalf of a plan's subscribers.
1067
+ * Iterates subscribers, pulls TX history, filters on fee.granter.
1068
+ */
1069
+ export async function computeFeeGrantGasCosts(planId, opts = {}) {
1070
+ return _computeFeeGrantGasCosts(planId, opts);
1071
+ }
1072
+
1009
1073
  // ─── Query Helpers (v25c) ────────────────────────────────────────────────────
1010
1074
 
1011
1075
  /**
@@ -1124,6 +1188,8 @@ export async function hasActiveSubscription(address, planId, lcdUrl) {
1124
1188
  return _hasActiveSubscription(address, planId, lcdUrl);
1125
1189
  }
1126
1190
 
1191
+
1192
+
1127
1193
  // ─── v26c: Display Helpers ───────────────────────────────────────────────────
1128
1194
 
1129
1195
  /**