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.
- package/app-helpers.js +55 -0
- package/chain/broadcast.js +27 -0
- package/chain/fee-grants.js +77 -7
- package/chain/queries.js +72 -0
- package/chain/rpc.js +59 -2
- package/cli.js +26 -5
- package/client.js +62 -6
- package/connection/connect.js +103 -17
- package/connection/disconnect.js +9 -4
- package/connection/logger.js +66 -0
- package/connection/resilience.js +12 -7
- package/connection/state.js +21 -12
- package/connection/tunnel.js +24 -8
- package/cosmjs-setup.js +42 -0
- package/docs/PRIVY-INTEGRATION.md +177 -0
- package/errors.js +167 -0
- package/index.js +70 -1
- package/node-connect.js +92 -40
- package/operator.js +24 -0
- package/package.json +11 -8
- package/session-manager.js +68 -0
- package/speedtest.js +139 -0
- package/test-all-logic.js +8 -6
- package/test-e2e.js +138 -0
- package/test-mainnet.js +2 -2
- package/test-plan-connect-e2e.js +235 -0
- package/test-subscription-flows.js +14 -4
- package/types/connection.d.ts +6 -2
package/connection/connect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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 [
|
|
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
|
-
//
|
|
108
|
-
|
|
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
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
795
|
-
|
|
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 {
|
package/connection/disconnect.js
CHANGED
|
@@ -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.
|
|
93
|
-
_endSessionOnChain(prev.sessionId, state.
|
|
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.
|
|
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 ||
|
|
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 };
|
package/connection/resilience.js
CHANGED
|
@@ -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 ||
|
|
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.
|
|
214
|
-
_endSessionOnChain(saved.sessionId, state.
|
|
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.
|
|
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.
|
|
339
|
-
_endSessionOnChain(sessionIdStr, state.
|
|
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.
|
|
347
|
+
state._wallet = null;
|
|
343
348
|
clearState();
|
|
344
349
|
},
|
|
345
350
|
};
|
package/connection/state.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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,
|
|
223
|
-
|
|
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.
|
|
265
|
-
_endSessionOnChain(stale.sessionId, _defaultState.
|
|
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.
|
|
312
|
-
_endSessionOnChain(conn.sessionId, _defaultState.
|
|
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.
|
|
321
|
-
_endSessionOnChain(conn.sessionId, _defaultState.
|
|
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.
|
|
332
|
-
_endSessionOnChain(conn.sessionId, _defaultState.
|
|
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;
|
package/connection/tunnel.js
CHANGED
|
@@ -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.
|
|
393
|
-
_endSessionOnChain(sessionIdStr, state.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
657
|
-
_endSessionOnChain(sessionIdStr, state.
|
|
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.
|
|
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
|
/**
|