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/node-connect.js CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  SentinelError, ValidationError, NodeError, ChainError, TunnelError, ErrorCodes,
63
63
  } from './errors.js';
64
64
  import { createNodeHttpsAgent, publicEndpointAgent } from './tls-trust.js';
65
+ import { withMnemonicRedaction } from './connection/logger.js';
65
66
 
66
67
  // CA-validated agent for LCD/RPC public endpoints (valid CA certs)
67
68
  const httpsAgent = publicEndpointAgent;
@@ -116,7 +117,9 @@ export class ConnectionState {
116
117
  this.systemProxy = false;
117
118
  this.connection = null; // { nodeAddress, serviceType, sessionId, connectedAt, socksPort? }
118
119
  this.savedProxyState = null;
119
- this._mnemonic = null; // Stored for session-end TX on disconnect (zeroed after use)
120
+ // v37 (security): store the derived OfflineSigner NOT the BIP-39 mnemonic.
121
+ // See connection/state.js for the rationale (heap-dump attack surface).
122
+ this._wallet = null; // OfflineSigner — used by _endSessionOnChain on disconnect
120
123
  this._feeGranter = null; // Stored for fee-granted session end on disconnect (0-P2P agents)
121
124
  _activeStates.add(this);
122
125
  }
@@ -128,8 +131,13 @@ export class ConnectionState {
128
131
  const _activeStates = new Set();
129
132
  const _defaultState = new ConnectionState();
130
133
 
131
- // Default logger — can be overridden per-call via opts.log
132
- let defaultLog = console.log;
134
+ // Default logger — can be overridden per-call via opts.log.
135
+ // Wrapped with mnemonic redaction so any 12–24 word BIP-39 phrase that ends up
136
+ // in a log argument is replaced with `[REDACTED MNEMONIC]` before reaching
137
+ // stdout. Defense-in-depth — the SDK does not currently log the mnemonic, but
138
+ // a future template-string bug or careless `JSON.stringify(opts)` won't leak
139
+ // it through the default logger.
140
+ let defaultLog = withMnemonicRedaction(console.log);
133
141
 
134
142
  // ─── Wallet Cache ────────────────────────────────────────────────────────────
135
143
  // v21: Cache wallet derivation (BIP39 → SLIP-10 is CPU-bound, ~300ms).
@@ -798,12 +806,12 @@ export async function tryFastReconnect(opts, state = _defaultState) {
798
806
  if (_killSwitchEnabled) disableKillSwitch();
799
807
  try { await disconnectWireGuard(); } catch {}
800
808
  // End session on chain (fire-and-forget)
801
- if (saved.sessionId && state._mnemonic) {
802
- _endSessionOnChain(saved.sessionId, state._mnemonic, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
809
+ if (saved.sessionId && state._wallet) {
810
+ _endSessionOnChain(saved.sessionId, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
803
811
  }
804
812
  state.wgTunnel = null;
805
813
  state.connection = null;
806
- state._mnemonic = null;
814
+ state._wallet = null;
807
815
  clearState();
808
816
  },
809
817
  };
@@ -923,11 +931,11 @@ export async function tryFastReconnect(opts, state = _defaultState) {
923
931
  if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
924
932
  if (state.systemProxy) clearSystemProxy(state);
925
933
  // End session on chain (fire-and-forget)
926
- if (sessionIdStr && state._mnemonic) {
927
- _endSessionOnChain(sessionIdStr, state._mnemonic, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
934
+ if (sessionIdStr && state._wallet) {
935
+ _endSessionOnChain(sessionIdStr, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
928
936
  }
929
937
  state.connection = null;
930
- state._mnemonic = null;
938
+ state._wallet = null;
931
939
  clearState();
932
940
  },
933
941
  };
@@ -993,9 +1001,11 @@ export async function connectDirect(opts) {
993
1001
 
994
1002
  // ── Fast Reconnect: check for saved credentials ──
995
1003
  if (!forceNewSession) {
996
- // Set mnemonic on state BEFORE fast reconnect needed for _endSessionOnChain() on disconnect
997
- (opts._state || _defaultState)._mnemonic = opts.mnemonic;
998
- const fast = await tryFastReconnect(opts, opts._state || _defaultState);
1004
+ // v37: Derive wallet up front so _endSessionOnChain() has a signer if fast reconnect succeeds.
1005
+ // cachedCreateWallet is keyed on mnemonic SHA256 — connectInternal() below reuses the same object.
1006
+ const fastState = opts._state || _defaultState;
1007
+ fastState._wallet = await cachedCreateWallet(opts.mnemonic);
1008
+ const fast = await tryFastReconnect(opts, fastState);
999
1009
  if (fast) {
1000
1010
  _circuitBreaker.delete(opts.nodeAddress);
1001
1011
  return fast;
@@ -1017,7 +1027,10 @@ export async function connectDirect(opts) {
1017
1027
  // lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
1018
1028
  onStaleDuplicate: (staleId) => {
1019
1029
  logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
1020
- _endSessionOnChain(staleId, opts.mnemonic, opts.feeGranter || null).catch(e => {
1030
+ // v37: pass the derived wallet (set on state above) instead of the mnemonic
1031
+ const w = (opts._state || _defaultState)._wallet;
1032
+ if (!w) { logFn?.(`[connect] No wallet on state — skipping stale session ${staleId} cleanup`); return; }
1033
+ _endSessionOnChain(staleId, w, opts.feeGranter || null).catch(e => {
1021
1034
  logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
1022
1035
  });
1023
1036
  },
@@ -1152,7 +1165,7 @@ export async function connectAuto(opts) {
1152
1165
  if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker);
1153
1166
 
1154
1167
  const maxAttempts = opts.maxAttempts || 3;
1155
- const logFn = opts.log || console.log;
1168
+ const logFn = opts.log || defaultLog;
1156
1169
  const errors = [];
1157
1170
 
1158
1171
  // If nodeAddress specified, try it first (skip circuit breaker check for explicit choice)
@@ -1443,6 +1456,38 @@ export async function connectViaSubscription(opts) {
1443
1456
 
1444
1457
  // ─── Shared Validation ───────────────────────────────────────────────────────
1445
1458
 
1459
+ /**
1460
+ * Validate a chain endpoint URL. Enforces https:// for non-localhost endpoints
1461
+ * to prevent attackers from coercing the SDK into broadcasting signed TXs over
1462
+ * cleartext HTTP. Localhost (loopback) is exempted for local-node development.
1463
+ *
1464
+ * @param {*} url - Value to validate (allowed: undefined/null, or string)
1465
+ * @param {string} fieldName - 'rpcUrl' or 'lcdUrl', used in error messages
1466
+ */
1467
+ function validateChainUrl(url, fieldName) {
1468
+ if (url == null) return;
1469
+ if (typeof url !== 'string') {
1470
+ throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must be a string URL`, { value: url });
1471
+ }
1472
+ let parsed;
1473
+ try { parsed = new URL(url); }
1474
+ catch { throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} is not a valid URL`, { value: url }); }
1475
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
1476
+ throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must use http:// or https:// (got ${parsed.protocol})`, { value: url });
1477
+ }
1478
+ const isLocalhost = parsed.hostname === 'localhost'
1479
+ || parsed.hostname === '127.0.0.1'
1480
+ || parsed.hostname === '::1'
1481
+ || parsed.hostname === '[::1]';
1482
+ if (parsed.protocol === 'http:' && !isLocalhost) {
1483
+ throw new ValidationError(
1484
+ ErrorCodes.INVALID_URL,
1485
+ `${fieldName} must use https:// for non-localhost endpoints. Cleartext HTTP leaks signed TXs and queries to network observers (got ${url}).`,
1486
+ { value: url },
1487
+ );
1488
+ }
1489
+ }
1490
+
1446
1491
  function validateConnectOpts(opts, fnName) {
1447
1492
  if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `${fnName}() requires an options object`);
1448
1493
  if (typeof opts.mnemonic !== 'string' || opts.mnemonic.trim().split(/\s+/).length < 12) {
@@ -1451,8 +1496,8 @@ function validateConnectOpts(opts, fnName) {
1451
1496
  if (typeof opts.nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(opts.nodeAddress)) {
1452
1497
  throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (47 characters)', { value: opts.nodeAddress });
1453
1498
  }
1454
- if (opts.rpcUrl != null && typeof opts.rpcUrl !== 'string') throw new ValidationError(ErrorCodes.INVALID_URL, 'rpcUrl must be a string URL', { value: opts.rpcUrl });
1455
- if (opts.lcdUrl != null && typeof opts.lcdUrl !== 'string') throw new ValidationError(ErrorCodes.INVALID_URL, 'lcdUrl must be a string URL', { value: opts.lcdUrl });
1499
+ validateChainUrl(opts.rpcUrl, 'rpcUrl');
1500
+ validateChainUrl(opts.lcdUrl, 'lcdUrl');
1456
1501
  }
1457
1502
 
1458
1503
  // ─── Shared Connect Flow (eliminates connectDirect/connectViaPlan duplication) ─
@@ -1490,13 +1535,17 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
1490
1535
  // v21: parallelized — saves ~300ms (was sequential)
1491
1536
  progress(onProgress, logFn, 'wallet', 'Setting up wallet...');
1492
1537
  checkAborted(signal);
1493
- const [{ wallet, account }, privKey] = await Promise.all([
1538
+ const [walletResult, privKey] = await Promise.all([
1494
1539
  cachedCreateWallet(opts.mnemonic),
1495
1540
  privKeyFromMnemonic(opts.mnemonic),
1496
1541
  ]);
1542
+ const { wallet, account } = walletResult;
1497
1543
 
1498
- // Store mnemonic + feeGranter on state for session-end TX on disconnect (fire-and-forget cleanup)
1499
- state._mnemonic = opts.mnemonic;
1544
+ // v37 (security): store the derived OfflineSigner NOT the raw BIP-39 phrase.
1545
+ // _endSessionOnChain only signs one TX on disconnect; it doesn't need the recovery
1546
+ // phrase, and keeping the phrase in heap for the full session expanded the
1547
+ // heap-dump attack surface unnecessarily.
1548
+ state._wallet = walletResult;
1500
1549
  state._feeGranter = opts.feeGranter || null;
1501
1550
 
1502
1551
  // 2. RPC connect + LCD lookup in parallel (independent network calls)
@@ -1884,12 +1933,12 @@ async function setupWireGuard({ remoteUrl, sessionId, privKey, fullTunnel, split
1884
1933
  if (_killSwitchEnabled) disableKillSwitch();
1885
1934
  try { await disconnectWireGuard(); } catch {} // tunnel may already be down
1886
1935
  // End session on chain (fire-and-forget)
1887
- if (sessionIdStr && state._mnemonic) {
1888
- _endSessionOnChain(sessionIdStr, state._mnemonic, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
1936
+ if (sessionIdStr && state._wallet) {
1937
+ _endSessionOnChain(sessionIdStr, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
1889
1938
  }
1890
1939
  state.wgTunnel = null;
1891
1940
  state.connection = null;
1892
- state._mnemonic = null;
1941
+ state._wallet = null;
1893
1942
  clearState();
1894
1943
  },
1895
1944
  };
@@ -2148,11 +2197,11 @@ async function setupV2Ray({ remoteUrl, serverHost, sessionId, privKey, v2rayExeP
2148
2197
  if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
2149
2198
  if (state.systemProxy) clearSystemProxy();
2150
2199
  // End session on chain (fire-and-forget)
2151
- if (sessionIdStr && state._mnemonic) {
2152
- _endSessionOnChain(sessionIdStr, state._mnemonic, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2200
+ if (sessionIdStr && state._wallet) {
2201
+ _endSessionOnChain(sessionIdStr, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2153
2202
  }
2154
2203
  state.connection = null;
2155
- state._mnemonic = null;
2204
+ state._wallet = null;
2156
2205
  clearState();
2157
2206
  },
2158
2207
  };
@@ -2183,8 +2232,8 @@ export function getStatus() {
2183
2232
  const stale = _defaultState.connection;
2184
2233
  _defaultState.connection = null;
2185
2234
  // End session on chain (fire-and-forget) to prevent stale session leaks
2186
- if (stale?.sessionId && _defaultState._mnemonic) {
2187
- _endSessionOnChain(stale.sessionId, _defaultState._mnemonic, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2235
+ if (stale?.sessionId && _defaultState._wallet) {
2236
+ _endSessionOnChain(stale.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2188
2237
  }
2189
2238
  clearState();
2190
2239
  events.emit('disconnected', { nodeAddress: stale.nodeAddress, serviceType: stale.serviceType, reason: 'phantom_state' });
@@ -2230,8 +2279,8 @@ export function getStatus() {
2230
2279
  // clean up stale state. Prevents ghost "connected" status after tunnel dies.
2231
2280
  if (!healthChecks.tunnelActive && !_defaultState.v2rayProc && !_defaultState.wgTunnel) {
2232
2281
  // Both tunnel handles are null — connection state is stale
2233
- if (conn?.sessionId && _defaultState._mnemonic) {
2234
- _endSessionOnChain(conn.sessionId, _defaultState._mnemonic, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2282
+ if (conn?.sessionId && _defaultState._wallet) {
2283
+ _endSessionOnChain(conn.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2235
2284
  }
2236
2285
  _defaultState.connection = null;
2237
2286
  clearState();
@@ -2239,8 +2288,8 @@ export function getStatus() {
2239
2288
  }
2240
2289
  if (_defaultState.wgTunnel && !healthChecks.tunnelActive) {
2241
2290
  // WireGuard state says connected but tunnel is dead — auto-cleanup
2242
- if (conn?.sessionId && _defaultState._mnemonic) {
2243
- _endSessionOnChain(conn.sessionId, _defaultState._mnemonic, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2291
+ if (conn?.sessionId && _defaultState._wallet) {
2292
+ _endSessionOnChain(conn.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2244
2293
  }
2245
2294
  _defaultState.wgTunnel = null;
2246
2295
  _defaultState.connection = null;
@@ -2250,8 +2299,8 @@ export function getStatus() {
2250
2299
  }
2251
2300
  if (_defaultState.v2rayProc && !healthChecks.tunnelActive) {
2252
2301
  // V2Ray process died — auto-cleanup
2253
- if (conn?.sessionId && _defaultState._mnemonic) {
2254
- _endSessionOnChain(conn.sessionId, _defaultState._mnemonic, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2302
+ if (conn?.sessionId && _defaultState._wallet) {
2303
+ _endSessionOnChain(conn.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
2255
2304
  }
2256
2305
  _defaultState.v2rayProc = null;
2257
2306
  _defaultState.connection = null;
@@ -2335,8 +2384,8 @@ async function _disconnectInternal(state, { endSession }) {
2335
2384
 
2336
2385
  if (endSession) {
2337
2386
  // Hard disconnect: broadcast MsgCancelSession — session settles and refunds deposit.
2338
- if (prev?.sessionId && state._mnemonic) {
2339
- _endSessionOnChain(prev.sessionId, state._mnemonic, state._feeGranter).catch(e => {
2387
+ if (prev?.sessionId && state._wallet) {
2388
+ _endSessionOnChain(prev.sessionId, state._wallet, state._feeGranter).catch(e => {
2340
2389
  console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
2341
2390
  });
2342
2391
  }
@@ -2348,7 +2397,7 @@ async function _disconnectInternal(state, { endSession }) {
2348
2397
  }
2349
2398
  } finally {
2350
2399
  // ALWAYS clear connection state — even if teardown threw
2351
- state._mnemonic = null;
2400
+ state._wallet = null;
2352
2401
  state._feeGranter = null;
2353
2402
  state.connection = null;
2354
2403
  clearState();
@@ -2415,11 +2464,14 @@ export async function disconnectAndEndSession() {
2415
2464
  * End a session on-chain. Best-effort, fire-and-forget.
2416
2465
  * Prevents stale session accumulation on nodes.
2417
2466
  * @param {string|bigint} sessionId - Session ID to end
2418
- * @param {string} mnemonic - BIP39 mnemonic for signing the TX
2467
+ * @param {object} walletObj - { wallet, account } from cachedCreateWallet (NOT the mnemonic).
2468
+ * v37 (security): the BIP-39 phrase is no longer kept on ConnectionState.
2469
+ * @param {string} [feeGranter] - Optional fee grant payer for 0-P2P agents
2419
2470
  * @private
2420
2471
  */
2421
- async function _endSessionOnChain(sessionId, mnemonic, feeGranter = null) {
2422
- const { wallet, account } = await cachedCreateWallet(mnemonic);
2472
+ async function _endSessionOnChain(sessionId, walletObj, feeGranter = null) {
2473
+ if (!walletObj) throw new SentinelError(ErrorCodes.INVALID_OPTIONS, '_endSessionOnChain requires a wallet object');
2474
+ const { wallet, account } = walletObj;
2423
2475
  const client = await tryWithFallback(
2424
2476
  RPC_ENDPOINTS,
2425
2477
  async (url) => createClient(url, wallet),
@@ -2465,7 +2517,7 @@ export async function recoverSession(opts) {
2465
2517
  if (!opts?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'recoverSession requires opts.nodeAddress');
2466
2518
  if (!opts?.mnemonic) throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'recoverSession requires opts.mnemonic');
2467
2519
 
2468
- const logFn = opts.log || console.log;
2520
+ const logFn = opts.log || defaultLog;
2469
2521
  const onProgress = opts.onProgress || null;
2470
2522
  const sessionId = BigInt(opts.sessionId);
2471
2523
  const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };
package/operator.js CHANGED
@@ -81,6 +81,16 @@ export {
81
81
  computeFeeGrantGasCosts,
82
82
  } from './cosmjs-setup.js';
83
83
 
84
+ export {
85
+ batchRevokeFeeGrants,
86
+ } from './operator/batch-revoke.js';
87
+
88
+ export {
89
+ queryFeeGrantHistory,
90
+ decodeFeeGrantEvent,
91
+ attr,
92
+ } from './operator/feegrant-history.js';
93
+
84
94
  // ─── Authz ──────────────────────────────────────────────────────────────────
85
95
 
86
96
  export {
@@ -134,3 +144,17 @@ export {
134
144
  encodeMsgUpdateSubscription,
135
145
  encodeMsgUpdateSession,
136
146
  } from './v3protocol.js';
147
+
148
+ // ─── Lease Batch Utilities ───────────────────────────────────────────────────
149
+
150
+ export {
151
+ autoLeaseNode,
152
+ batchLeaseNodes,
153
+ } from './operator/auto-lease.js';
154
+ // ─── Plan Ownership Pre-Flight ──────────────────────────────────────────────
155
+
156
+ export {
157
+ assertPlanOwnership,
158
+ PlanOwnershipError,
159
+ walletToProviderAddr,
160
+ } from './operator/plan-ownership.js';
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "blue-js-sdk",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Decentralized VPN SDK for the Sentinel P2P bandwidth network. WireGuard + V2Ray tunnels, Cosmos blockchain, 900+ nodes. Tested on Windows. macOS/Linux support included but untested.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "types/index.d.ts",
8
8
  "bin": {
9
- "sentinel": "./cli/index.js"
9
+ "sentinel": "cli/index.js"
10
10
  },
11
11
  "exports": {
12
12
  ".": {
@@ -63,6 +63,9 @@
63
63
  "proto:generate": "npx protoc --ts_proto_out=generated --ts_proto_opt=esModuleInterop=true --ts_proto_opt=env=node --ts_proto_opt=outputTypeRegistry=true --proto_path=proto proto/sentinel/types/v1/*.proto proto/sentinel/node/v3/*.proto proto/sentinel/session/v3/*.proto proto/sentinel/plan/v3/*.proto proto/sentinel/subscription/v3/*.proto proto/sentinel/lease/v1/*.proto proto/sentinel/provider/v2/*.proto",
64
64
  "test": "node test/smoke.js",
65
65
  "test:exports": "node -e \"import('./index.js').then(m => console.log(Object.keys(m).length + ' exports OK'))\"",
66
+ "test:privy": "node test/privy-cosmos-signer.test.mjs && node test/privy-client-integration.test.mjs",
67
+ "test:privy:live": "node test/privy-live-chain.test.mjs",
68
+ "test:privy:server": "node test/privy-real-server.test.mjs",
66
69
  "postinstall": "node bin/setup.js || true"
67
70
  },
68
71
  "keywords": [
@@ -86,18 +89,18 @@
86
89
  "license": "MIT",
87
90
  "repository": {
88
91
  "type": "git",
89
- "url": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk.git"
92
+ "url": "git+https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk.git"
90
93
  },
91
94
  "bugs": {
92
95
  "url": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk/issues"
93
96
  },
94
97
  "homepage": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk#readme",
95
98
  "dependencies": {
96
- "@cosmjs/amino": "0.32.2",
97
- "@cosmjs/crypto": "0.32.2",
98
- "@cosmjs/encoding": "0.32.2",
99
- "@cosmjs/proto-signing": "0.32.2",
100
- "@cosmjs/stargate": "0.32.2",
99
+ "@cosmjs/amino": "0.32.4",
100
+ "@cosmjs/crypto": "0.32.4",
101
+ "@cosmjs/encoding": "0.32.4",
102
+ "@cosmjs/proto-signing": "0.32.4",
103
+ "@cosmjs/stargate": "0.32.4",
101
104
  "@noble/curves": "^1.4.0",
102
105
  "axios": "^1.7.0",
103
106
  "dotenv": "^16.4.0",
@@ -329,3 +329,71 @@ export class SessionManager {
329
329
  if (this._logger) this._logger(msg);
330
330
  }
331
331
  }
332
+
333
+ // ─── Multi-message tx session extraction ─────────────────────────────────────
334
+
335
+ /**
336
+ * Extract session IDs keyed by node_address from a multi-message transaction.
337
+ *
338
+ * Background: a single tx can carry up to N MsgStartSession messages. The chain
339
+ * emits a `session_id` per session, but on most chain heights the events do NOT
340
+ * include `node_address` alongside the `session_id`. Confirmed empirically
341
+ * 2026-03-23. Naively pairing events to messages by index causes address
342
+ * mismatch, so any session_id without a colocated node_address is reported as
343
+ * an "orphan" — the caller MUST resolve it to a node by querying the chain.
344
+ *
345
+ * Returns a Map with two non-enumerable hint fields attached:
346
+ * - `_orphanIds` Array<bigint> session IDs needing chain lookup
347
+ * - `_needsChainLookup` boolean true if any expected node is unmapped
348
+ *
349
+ * @param {{ events?: Array }} txResult - Tx broadcast result (RPC or LCD shape)
350
+ * @param {string[]} [nodeAddrs] - Expected node addresses (used to compute
351
+ * `_needsChainLookup`). If omitted, the flag is true whenever orphans exist.
352
+ * @returns {Map<string, bigint> & { _orphanIds: bigint[], _needsChainLookup: boolean }}
353
+ *
354
+ * @example
355
+ * const map = extractSessionMap(txResult, batch.map(b => b.node.address));
356
+ * if (map._needsChainLookup) {
357
+ * for (const sid of map._orphanIds) {
358
+ * const session = await querySessionById(client, sid);
359
+ * map.set(session.nodeAddress, sid);
360
+ * }
361
+ * }
362
+ */
363
+ export function extractSessionMap(txResult, nodeAddrs) {
364
+ const map = new Map();
365
+ const orphanIds = [];
366
+
367
+ for (const event of (txResult?.events || [])) {
368
+ if (!/session/i.test(event.type)) continue;
369
+ let sessionId = null;
370
+ let nodeAddr = null;
371
+ for (const attr of (event.attributes || [])) {
372
+ const k = typeof attr.key === 'string'
373
+ ? attr.key
374
+ : Buffer.from(attr.key, 'base64').toString('utf8');
375
+ const rawV = typeof attr.value === 'string'
376
+ ? attr.value
377
+ : Buffer.from(attr.value, 'base64').toString('utf8');
378
+ const clean = rawV.replace(/"/g, '');
379
+ if (k === 'session_id' || k === 'id') {
380
+ try {
381
+ const id = BigInt(clean);
382
+ if (id > 0n) sessionId = id;
383
+ } catch { /* not a numeric id; skip */ }
384
+ }
385
+ if (k === 'node_address') nodeAddr = clean;
386
+ }
387
+ if (sessionId && nodeAddr) {
388
+ map.set(nodeAddr, sessionId);
389
+ } else if (sessionId) {
390
+ orphanIds.push(sessionId);
391
+ }
392
+ }
393
+
394
+ // Chain events NEVER include node_address on most heights. Caller resolves.
395
+ map._orphanIds = orphanIds;
396
+ map._needsChainLookup = orphanIds.length > 0
397
+ && map.size < (nodeAddrs?.length || Number.POSITIVE_INFINITY);
398
+ return map;
399
+ }
package/speedtest.js CHANGED
@@ -565,3 +565,142 @@ export function compareSpeedTests(before, after) {
565
565
  };
566
566
  }
567
567
 
568
+ // ─── Post-Tunnel Google Accessibility Checks ────────────────────────────────
569
+ //
570
+ // Some nodes route Cloudflare fine but block Google (region-specific egress
571
+ // filtering). Speedtest passing != general internet works. These helpers do
572
+ // a cheap, latency-only HTTPS hit against `google.com` after the tunnel is
573
+ // up — useful as a fast pre-flight before a full speedtest, or as a separate
574
+ // "general internet works" signal in audit results.
575
+ //
576
+ // Two flavors:
577
+ // - checkGoogleDirect: for tunnels where all traffic is tunneled (WireGuard)
578
+ // - checkGoogleViaSocks5: for tunnels where traffic goes via local SOCKS5 (V2Ray)
579
+ //
580
+ // Direct check uses an external resolver (8.8.8.8 / 1.1.1.1) to resolve
581
+ // `www.google.com` BEFORE the tunnel may have working DNS, then issues HTTPS
582
+ // to the IP with `Host: www.google.com` + SNI. Works on tunnels that don't
583
+ // route DNS or that point to dead resolvers.
584
+
585
+ const GOOGLE_HOST = 'www.google.com';
586
+ const GOOGLE_DNS_CACHE_TTL = 5 * 60_000;
587
+ let cachedGoogleIp = null;
588
+ let cachedGoogleTime = 0;
589
+
590
+ /**
591
+ * Resolve `www.google.com` to an A record using public resolvers, with
592
+ * fallback to the system resolver and finally `dns.lookup`. Result is cached
593
+ * for {@link GOOGLE_DNS_CACHE_TTL} ms across calls in the same process.
594
+ *
595
+ * @returns {Promise<string|null>} IPv4 address or null if every resolver failed
596
+ */
597
+ export async function resolveGoogleIp() {
598
+ if (cachedGoogleIp && Date.now() - cachedGoogleTime < GOOGLE_DNS_CACHE_TTL) return cachedGoogleIp;
599
+ try {
600
+ const resolver = new dns.Resolver();
601
+ resolver.setServers(['8.8.8.8', '1.1.1.1']);
602
+ const addrs = await new Promise((resolve, reject) => {
603
+ resolver.resolve4(GOOGLE_HOST, (err, addresses) => err ? reject(err) : resolve(addresses));
604
+ });
605
+ if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
606
+ } catch { }
607
+ try {
608
+ const addrs = await dns.promises.resolve4(GOOGLE_HOST);
609
+ if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
610
+ } catch { }
611
+ try {
612
+ const { address } = await dns.promises.lookup(GOOGLE_HOST);
613
+ cachedGoogleIp = address;
614
+ cachedGoogleTime = Date.now();
615
+ return cachedGoogleIp;
616
+ } catch { }
617
+ return null;
618
+ }
619
+
620
+ /**
621
+ * Check if `google.com` is reachable through the ambient network path
622
+ * (typically a WireGuard tunnel where all traffic is tunneled). Tries the
623
+ * resolved IP first (with `Host` header + SNI), then falls back to the
624
+ * hostname directly. Any successful TLS handshake counts as reachable.
625
+ *
626
+ * @param {number} [timeoutMs=10000]
627
+ * @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
628
+ */
629
+ export async function checkGoogleDirect(timeoutMs = 10_000) {
630
+ const start = Date.now();
631
+ const targetIp = await resolveGoogleIp();
632
+ const targets = [];
633
+ if (targetIp) targets.push(`https://${targetIp}/`);
634
+ targets.push(`https://${GOOGLE_HOST}/`);
635
+
636
+ for (const url of targets) {
637
+ try {
638
+ await new Promise((resolve, reject) => {
639
+ const parsed = new URL(url);
640
+ const options = {
641
+ hostname: parsed.hostname,
642
+ path: '/',
643
+ method: 'GET',
644
+ rejectUnauthorized: false,
645
+ agent: false,
646
+ headers: { Host: GOOGLE_HOST },
647
+ servername: GOOGLE_HOST,
648
+ };
649
+ const req = https.get(options, (res) => {
650
+ res.destroy();
651
+ resolve();
652
+ });
653
+ req.on('error', reject);
654
+ req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error('timeout')); });
655
+ });
656
+ return {
657
+ googleAccessible: true,
658
+ googleLatencyMs: Date.now() - start,
659
+ googleError: null,
660
+ };
661
+ } catch { }
662
+ }
663
+
664
+ return {
665
+ googleAccessible: false,
666
+ googleLatencyMs: null,
667
+ googleError: 'Google unreachable through tunnel',
668
+ };
669
+ }
670
+
671
+ /**
672
+ * Check if `google.com` is reachable through a V2Ray SOCKS5 proxy on
673
+ * localhost. Required because native Node `fetch` silently ignores SOCKS
674
+ * proxy agents — must use axios + SocksProxyAgent.
675
+ *
676
+ * @param {number} proxyPort - Local V2Ray SOCKS5 port (e.g. 1080)
677
+ * @param {number} [timeoutMs=10000]
678
+ * @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
679
+ */
680
+ export async function checkGoogleViaSocks5(proxyPort, timeoutMs = 10_000) {
681
+ const start = Date.now();
682
+ const agent = new SocksProxyAgent(`socks5://127.0.0.1:${proxyPort}`);
683
+ try {
684
+ await axios.get(`https://${GOOGLE_HOST}/`, {
685
+ timeout: timeoutMs,
686
+ httpAgent: agent,
687
+ httpsAgent: agent,
688
+ maxRedirects: 2,
689
+ validateStatus: () => true,
690
+ });
691
+ return {
692
+ googleAccessible: true,
693
+ googleLatencyMs: Date.now() - start,
694
+ googleError: null,
695
+ };
696
+ } catch (err) {
697
+ return {
698
+ googleAccessible: false,
699
+ googleLatencyMs: null,
700
+ googleError: err.message || 'Google unreachable through SOCKS5',
701
+ };
702
+ } finally {
703
+ agent.destroy();
704
+ }
705
+ }
706
+
package/test-all-logic.js CHANGED
@@ -29,12 +29,14 @@ t('formatUptime 0', sdk.formatUptime(0) === '0s');
29
29
 
30
30
  // ═══ ADDRESS CONVERSION (6) ═══
31
31
  console.log('═══ ADDRESS CONVERSION ═══');
32
- t('shortAddress truncates', sdk.shortAddress('sent1example9pqrse8q4m6lz8alxqv5hkx3fkxe0q').includes('...'));
32
+ // Valid sent1 address derived from BIP39 test mnemonic ("abandon abandon...about")
33
+ const ADDR = 'sent19rl4cm2hmr8afy4kldpxz3fka4jguq0a8mmym6';
34
+ t('shortAddress truncates', sdk.shortAddress(ADDR).includes('...'));
33
35
  t('shortAddress short passthrough', sdk.shortAddress('sent1abc') === 'sent1abc');
34
- t('sentToSentprov', sdk.sentToSentprov('sent1example9pqrse8q4m6lz8alxqv5hkx3fkxe0q').startsWith('sentprov'));
35
- t('sentToSentnode', sdk.sentToSentnode('sent1example9pqrse8q4m6lz8alxqv5hkx3fkxe0q').startsWith('sentnode'));
36
- t('sentprovToSent', sdk.sentprovToSent(sdk.sentToSentprov('sent1example9pqrse8q4m6lz8alxqv5hkx3fkxe0q')).startsWith('sent1'));
37
- t('isSameKey cross-prefix', sdk.isSameKey('sent1example9pqrse8q4m6lz8alxqv5hkx3fkxe0q', sdk.sentToSentprov('sent1example9pqrse8q4m6lz8alxqv5hkx3fkxe0q')));
36
+ t('sentToSentprov', sdk.sentToSentprov(ADDR).startsWith('sentprov'));
37
+ t('sentToSentnode', sdk.sentToSentnode(ADDR).startsWith('sentnode'));
38
+ t('sentprovToSent', sdk.sentprovToSent(sdk.sentToSentprov(ADDR)).startsWith('sent1'));
39
+ t('isSameKey cross-prefix', sdk.isSameKey(ADDR, sdk.sentToSentprov(ADDR)));
38
40
 
39
41
  // ═══ VALIDATION (7) ═══
40
42
  console.log('═══ VALIDATION ═══');
@@ -78,7 +80,7 @@ t('no duplicate DNS', new Set(sdk.resolveDnsServers().split(', ')).size === sdk.
78
80
 
79
81
  // ═══ ERROR SYSTEM (20) ═══
80
82
  console.log('═══ ERROR SYSTEM ═══');
81
- t('33 error codes', Object.values(sdk.ErrorCodes).length === 33);
83
+ t('42 error codes', Object.values(sdk.ErrorCodes).length === 42);
82
84
  t('all have messages', Object.values(sdk.ErrorCodes).every(c => sdk.userMessage(c) !== 'An unexpected error occurred.'));
83
85
  t('unknown default', sdk.userMessage('FAKE') === 'An unexpected error occurred.');
84
86
  t('INSUFFICIENT fatal', sdk.ERROR_SEVERITY[sdk.ErrorCodes.INSUFFICIENT_BALANCE] === 'fatal');