blue-js-sdk 2.6.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.
@@ -15,7 +15,7 @@ import {
15
15
  import {
16
16
  createClient, privKeyFromMnemonic, broadcastWithFeeGrant,
17
17
  extractId, findExistingSession, getBalance, MSG_TYPES, queryNode,
18
- isMnemonicValid, filterNodes,
18
+ isMnemonicValid, filterNodes, checkFeeGrant,
19
19
  } from '../cosmjs-setup.js';
20
20
  import { nodeStatusV3, waitForPort } from '../v3protocol.js';
21
21
  import {
@@ -40,11 +40,51 @@ import {
40
40
  import { performHandshake, validateTunnelRequirements, killV2RayProc, verifyDependencies } from './tunnel.js';
41
41
  import { verifyConnection } from './state.js';
42
42
  import { registerCleanupHandlers } from './disconnect.js';
43
+ import { withMnemonicRedaction } from './logger.js';
43
44
 
44
- let defaultLog = console.log;
45
+ // Default logger wraps console.log with a mnemonic redactor. Anything that
46
+ // resembles a 12–24 word BIP-39 phrase in a log argument is replaced with
47
+ // `[REDACTED MNEMONIC]` before it reaches stdout. Defense-in-depth — the SDK
48
+ // does not currently log the mnemonic, but a future template-string bug or
49
+ // careless `JSON.stringify(opts)` won't leak it through the default logger.
50
+ let defaultLog = withMnemonicRedaction(console.log);
45
51
 
46
52
  // ─── Shared Validation ───────────────────────────────────────────────────────
47
53
 
54
+ /**
55
+ * Validate a chain endpoint URL. Enforces https:// to prevent attackers from
56
+ * coercing the SDK into broadcasting signed TXs over cleartext HTTP — a passive
57
+ * network observer on http://attacker.example/rpc would see the full TX body
58
+ * and could correlate it to the user's wallet address. Localhost is exempted
59
+ * for local-node development.
60
+ *
61
+ * @param {*} url - Value to validate (allowed: undefined/null, or string)
62
+ * @param {string} fieldName - 'rpcUrl' or 'lcdUrl', used in error messages
63
+ */
64
+ function validateChainUrl(url, fieldName) {
65
+ if (url == null) return;
66
+ if (typeof url !== 'string') {
67
+ throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must be a string URL`, { value: url });
68
+ }
69
+ let parsed;
70
+ try { parsed = new URL(url); }
71
+ catch { throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} is not a valid URL`, { value: url }); }
72
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
73
+ throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must use http:// or https:// (got ${parsed.protocol})`, { value: url });
74
+ }
75
+ const isLocalhost = parsed.hostname === 'localhost'
76
+ || parsed.hostname === '127.0.0.1'
77
+ || parsed.hostname === '::1'
78
+ || parsed.hostname === '[::1]';
79
+ if (parsed.protocol === 'http:' && !isLocalhost) {
80
+ throw new ValidationError(
81
+ ErrorCodes.INVALID_URL,
82
+ `${fieldName} must use https:// for non-localhost endpoints. Cleartext HTTP leaks signed TXs and queries to network observers (got ${url}).`,
83
+ { value: url },
84
+ );
85
+ }
86
+ }
87
+
48
88
  function validateConnectOpts(opts, fnName) {
49
89
  if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `${fnName}() requires an options object`);
50
90
  if (typeof opts.mnemonic !== 'string') {
@@ -60,8 +100,8 @@ function validateConnectOpts(opts, fnName) {
60
100
  if (typeof opts.nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(opts.nodeAddress)) {
61
101
  throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (47 characters)', { value: opts.nodeAddress });
62
102
  }
63
- if (opts.rpcUrl != null && typeof opts.rpcUrl !== 'string') throw new ValidationError(ErrorCodes.INVALID_URL, 'rpcUrl must be a string URL', { value: opts.rpcUrl });
64
- if (opts.lcdUrl != null && typeof opts.lcdUrl !== 'string') throw new ValidationError(ErrorCodes.INVALID_URL, 'lcdUrl must be a string URL', { value: opts.lcdUrl });
103
+ validateChainUrl(opts.rpcUrl, 'rpcUrl');
104
+ validateChainUrl(opts.lcdUrl, 'lcdUrl');
65
105
  }
66
106
 
67
107
  // ─── Shared Connect Flow (eliminates connectDirect/connectViaPlan duplication) ─
@@ -99,13 +139,17 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
99
139
  // v21: parallelized — saves ~300ms (was sequential)
100
140
  progress(onProgress, logFn, 'wallet', 'Setting up wallet...');
101
141
  checkAborted(signal);
102
- const [{ wallet, account }, privKey] = await Promise.all([
142
+ const [walletResult, privKey] = await Promise.all([
103
143
  cachedCreateWallet(opts.mnemonic),
104
144
  privKeyFromMnemonic(opts.mnemonic),
105
145
  ]);
146
+ const { wallet, account } = walletResult;
106
147
 
107
- // Store mnemonic on state for session-end TX on disconnect (fire-and-forget cleanup)
108
- state._mnemonic = opts.mnemonic;
148
+ // v37 (security): store the derived OfflineSigner NOT the BIP-39 mnemonic.
149
+ // _endSessionOnChain only needs to sign one TX on disconnect; it doesn't need
150
+ // the recovery phrase. Holding the mnemonic in heap for the full session
151
+ // expanded the heap-dump attack surface unnecessarily.
152
+ state._wallet = walletResult;
109
153
 
110
154
  // 2. RPC connect + LCD lookup in parallel (independent network calls)
111
155
  // v21: parallelized — saves 1-3s (was sequential)
@@ -381,9 +425,12 @@ export async function connectDirect(opts) {
381
425
 
382
426
  // ── Fast Reconnect: check for saved credentials ──
383
427
  if (!forceNewSession) {
384
- // Set mnemonic on state BEFORE fast reconnect needed for _endSessionOnChain() on disconnect
385
- (opts._state || _defaultState)._mnemonic = opts.mnemonic;
386
- const fast = await tryFastReconnect(opts, opts._state || _defaultState);
428
+ // v37: Derive wallet BEFORE fast reconnect and stash on state — _endSessionOnChain()
429
+ // needs the signer, not the mnemonic. Same wallet object is reused by connectInternal()
430
+ // below if fast reconnect misses (cachedCreateWallet keys on mnemonic SHA256).
431
+ const fastState = opts._state || _defaultState;
432
+ fastState._wallet = await cachedCreateWallet(opts.mnemonic);
433
+ const fast = await tryFastReconnect(opts, fastState);
387
434
  if (fast) {
388
435
  clearCircuitBreaker(opts.nodeAddress);
389
436
  return fast;
@@ -405,7 +452,10 @@ export async function connectDirect(opts) {
405
452
  // lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
406
453
  onStaleDuplicate: (staleId) => {
407
454
  logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
408
- _endSessionOnChain(staleId, opts.mnemonic).catch(e => {
455
+ // v37: pass the derived wallet (set on state above) instead of the mnemonic
456
+ const w = (opts._state || _defaultState)._wallet;
457
+ if (!w) { logFn?.(`[connect] No wallet on state — skipping stale session ${staleId} cleanup`); return; }
458
+ _endSessionOnChain(staleId, w).catch(e => {
409
459
  logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
410
460
  });
411
461
  },
@@ -548,7 +598,7 @@ export async function connectAuto(opts) {
548
598
  if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker);
549
599
 
550
600
  const maxAttempts = opts.maxAttempts || 3;
551
- const logFn = opts.log || console.log;
601
+ const logFn = opts.log || defaultLog;
552
602
  const errors = [];
553
603
 
554
604
  // If nodeAddress specified, try it first (skip circuit breaker check for explicit choice)
@@ -701,6 +751,22 @@ export async function connectViaPlan(opts) {
701
751
  // Fee grant: the app passes the plan owner's address as feeGranter.
702
752
  const feeGranter = opts.feeGranter || null;
703
753
 
754
+ // Opt-in precheck: verify the grant exists and is not expired before the TX.
755
+ // Builders who prefer fail-fast over silent user-pay fallback set requireFeeGrant.
756
+ if (feeGranter && opts.requireFeeGrant === true) {
757
+ const status = await checkFeeGrant(lcdUrl, feeGranter, account.address);
758
+ if (!status.exists) {
759
+ throw new ChainError(ErrorCodes.FEE_GRANT_MISSING_AT_START,
760
+ `Required fee grant missing from ${feeGranter} to ${account.address}`,
761
+ { granter: feeGranter, grantee: account.address });
762
+ }
763
+ if (status.expired) {
764
+ throw new ChainError(ErrorCodes.FEE_GRANT_EXPIRED,
765
+ `Required fee grant expired at ${status.expiresAt?.toISOString()}`,
766
+ { granter: feeGranter, grantee: account.address, expiresAt: status.expiresAt });
767
+ }
768
+ }
769
+
704
770
  progress(null, opts.log || defaultLog, 'session', `Subscribing to plan ${opts.planId} + starting session${feeGranter ? ' (fee granted)' : ''}...`);
705
771
 
706
772
  let result;
@@ -708,8 +774,10 @@ export async function connectViaPlan(opts) {
708
774
  try {
709
775
  result = await broadcastWithFeeGrant(client, account.address, [msg], feeGranter);
710
776
  } catch (feeErr) {
711
- // Fee grant TX failed — fall back to user-paid
712
- progress(null, opts.log || defaultLog, 'session', 'Fee grant failed, paying gas from wallet...');
777
+ if (opts.requireFeeGrant === true) throw feeErr;
778
+ // Fee grant TX failed fall back to user-paid (default behavior)
779
+ const reason = feeErr?.message ? `: ${feeErr.message}` : '';
780
+ progress(null, opts.log || defaultLog, 'session', `Fee grant failed${reason}, paying gas from wallet...`);
713
781
  result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
714
782
  }
715
783
  } else {
@@ -770,7 +838,7 @@ export async function connectViaSubscription(opts) {
770
838
  try {
771
839
 
772
840
  async function subPayment(ctx) {
773
- const { client, account, logFn, onProgress, signal } = ctx;
841
+ const { client, account, lcd: lcdUrl, logFn, onProgress, signal } = ctx;
774
842
  const msg = {
775
843
  typeUrl: MSG_TYPES.SUB_START_SESSION,
776
844
  value: {
@@ -784,6 +852,22 @@ export async function connectViaSubscription(opts) {
784
852
 
785
853
  // Fee grant: operator pays gas for the agent (e.g., x402 managed plan flow)
786
854
  const feeGranter = opts.feeGranter || null;
855
+
856
+ // Opt-in precheck: verify the grant exists and is not expired before the TX.
857
+ if (feeGranter && opts.requireFeeGrant === true) {
858
+ const status = await checkFeeGrant(lcdUrl, feeGranter, account.address);
859
+ if (!status.exists) {
860
+ throw new ChainError(ErrorCodes.FEE_GRANT_MISSING_AT_START,
861
+ `Required fee grant missing from ${feeGranter} to ${account.address}`,
862
+ { granter: feeGranter, grantee: account.address });
863
+ }
864
+ if (status.expired) {
865
+ throw new ChainError(ErrorCodes.FEE_GRANT_EXPIRED,
866
+ `Required fee grant expired at ${status.expiresAt?.toISOString()}`,
867
+ { granter: feeGranter, grantee: account.address, expiresAt: status.expiresAt });
868
+ }
869
+ }
870
+
787
871
  progress(null, opts.log || defaultLog, 'session', `Starting session via subscription ${opts.subscriptionId}${feeGranter ? ' (fee granted)' : ''}...`);
788
872
 
789
873
  let result;
@@ -791,8 +875,10 @@ export async function connectViaSubscription(opts) {
791
875
  try {
792
876
  result = await broadcastWithFeeGrant(client, account.address, [msg], feeGranter);
793
877
  } catch (feeErr) {
794
- // Fee grant TX failed — fall back to user-paid
795
- progress(null, opts.log || defaultLog, 'session', 'Fee grant failed, paying gas from wallet...');
878
+ if (opts.requireFeeGrant === true) throw feeErr;
879
+ // Fee grant TX failed fall back to user-paid (default behavior)
880
+ const reason = feeErr?.message ? `: ${feeErr.message}` : '';
881
+ progress(null, opts.log || defaultLog, 'session', `Fee grant failed${reason}, paying gas from wallet...`);
796
882
  result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
797
883
  }
798
884
  } else {
@@ -26,6 +26,11 @@ 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 ──────────────────────────────────────────────────────────────
31
36
  //
@@ -89,8 +94,8 @@ async function _disconnectInternal(state, { endSession }) {
89
94
 
90
95
  if (endSession) {
91
96
  // Hard disconnect: broadcast MsgCancelSession — session settles and refunds deposit.
92
- if (prev?.sessionId && state._mnemonic) {
93
- _endSessionOnChain(prev.sessionId, state._mnemonic).catch(e => {
97
+ if (prev?.sessionId && state._wallet) {
98
+ _endSessionOnChain(prev.sessionId, state._wallet).catch(e => {
94
99
  console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
95
100
  });
96
101
  }
@@ -102,7 +107,7 @@ async function _disconnectInternal(state, { endSession }) {
102
107
  }
103
108
  } finally {
104
109
  // ALWAYS clear connection state — even if teardown threw
105
- state._mnemonic = null;
110
+ state._wallet = null;
106
111
  state.connection = null;
107
112
  clearState();
108
113
  clearWalletCache(); // v34: Clear cached wallet objects (private keys) from memory
@@ -204,7 +209,7 @@ export async function recoverSession(opts) {
204
209
  if (!opts?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'recoverSession requires opts.nodeAddress');
205
210
  if (!opts?.mnemonic) throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'recoverSession requires opts.mnemonic');
206
211
 
207
- const logFn = opts.log || console.log;
212
+ const logFn = opts.log || defaultLog;
208
213
  const onProgress = opts.onProgress || null;
209
214
  const sessionId = BigInt(opts.sessionId);
210
215
  const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };
@@ -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,6 +85,7 @@ 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,
@@ -812,6 +815,20 @@ export async function queryFeeGrant(lcdUrl, granter, grantee) {
812
815
  return _queryFeeGrant(lcdUrl, granter, grantee);
813
816
  }
814
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
+
815
832
  /**
816
833
  * Broadcast a TX with fee paid by a granter (fee grant).
817
834
  * The grantee signs; the granter pays gas via their fee allowance.
@@ -953,6 +970,29 @@ export async function getPlanStats(planId, ownerAddress, opts = {}) {
953
970
  return _getPlanStats(planId, ownerAddress, opts);
954
971
  }
955
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
+
956
996
  // ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
957
997
 
958
998
  /**
@@ -1148,6 +1188,8 @@ export async function hasActiveSubscription(address, planId, lcdUrl) {
1148
1188
  return _hasActiveSubscription(address, planId, lcdUrl);
1149
1189
  }
1150
1190
 
1191
+
1192
+
1151
1193
  // ─── v26c: Display Helpers ───────────────────────────────────────────────────
1152
1194
 
1153
1195
  /**