blue-js-sdk 2.4.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +3 -3
  2. package/app-helpers.js +55 -0
  3. package/chain/broadcast.js +27 -0
  4. package/chain/fee-grants.js +271 -5
  5. package/chain/index.js +8 -2
  6. package/chain/queries.js +177 -3
  7. package/chain/rpc.js +117 -4
  8. package/cli.js +26 -5
  9. package/client.js +79 -7
  10. package/connection/connect.js +119 -21
  11. package/connection/disconnect.js +93 -12
  12. package/connection/index.js +2 -0
  13. package/connection/logger.js +66 -0
  14. package/connection/resilience.js +12 -7
  15. package/connection/state.js +21 -12
  16. package/connection/tunnel.js +24 -8
  17. package/cosmjs-setup.js +68 -2
  18. package/docs/PRIVY-INTEGRATION.md +177 -0
  19. package/errors.js +167 -0
  20. package/index.js +75 -2
  21. package/node-connect.js +190 -50
  22. package/operator.js +26 -0
  23. package/package.json +11 -11
  24. package/session-manager.js +68 -0
  25. package/speedtest.js +139 -0
  26. package/test-all-logic.js +8 -6
  27. package/test-e2e.js +138 -0
  28. package/test-mainnet.js +2 -2
  29. package/test-plan-connect-e2e.js +235 -0
  30. package/test-subscription-flows.js +14 -4
  31. package/types/connection.d.ts +6 -2
  32. package/types/index.d.ts +2 -2
  33. package/ai-path/ADMIN-ELEVATION.md +0 -116
  34. package/ai-path/AI-MANIFESTO.md +0 -185
  35. package/ai-path/BREAKING.md +0 -74
  36. package/ai-path/CHECKLIST.md +0 -619
  37. package/ai-path/CONNECTION-STEPS.md +0 -724
  38. package/ai-path/DECISION-TREE.md +0 -422
  39. package/ai-path/DEPENDENCIES.md +0 -459
  40. package/ai-path/E2E-FLOW.md +0 -1707
  41. package/ai-path/FAILURES.md +0 -410
  42. package/ai-path/GUIDE.md +0 -1315
  43. package/ai-path/README.md +0 -599
  44. package/ai-path/SPLIT-TUNNEL.md +0 -266
  45. package/ai-path/cli.js +0 -548
  46. package/ai-path/connect.js +0 -1028
  47. package/ai-path/discover.js +0 -178
  48. package/ai-path/environment.js +0 -266
  49. package/ai-path/errors.js +0 -86
  50. package/ai-path/examples/autonomous-agent.mjs +0 -220
  51. package/ai-path/examples/multi-region.mjs +0 -174
  52. package/ai-path/examples/one-shot.mjs +0 -31
  53. package/ai-path/index.js +0 -79
  54. package/ai-path/pricing.js +0 -137
  55. package/ai-path/recommend.js +0 -413
  56. package/ai-path/run-admin.vbs +0 -25
  57. package/ai-path/setup.js +0 -291
  58. package/ai-path/wallet.js +0 -137
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;
@@ -1011,7 +1021,20 @@ export async function connectDirect(opts) {
1011
1021
  if (!forceNewSession) {
1012
1022
  progress(onProgress, logFn, 'session', 'Checking for existing session...');
1013
1023
  checkAborted(signal);
1014
- sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress);
1024
+ sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress, {
1025
+ // Dedup: if multiple active sessions exist for this node (stale duplicates from
1026
+ // crashes or multi-client wallets), keep the highest-ID one. Silently cancel stale
1027
+ // lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
1028
+ onStaleDuplicate: (staleId) => {
1029
+ logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
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 => {
1034
+ logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
1035
+ });
1036
+ },
1037
+ });
1015
1038
  if (sessionId && isSessionPoisoned(String(sessionId))) {
1016
1039
  progress(onProgress, logFn, 'session', `Session ${sessionId} previously failed — skipping`);
1017
1040
  sessionId = null;
@@ -1142,7 +1165,7 @@ export async function connectAuto(opts) {
1142
1165
  if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker);
1143
1166
 
1144
1167
  const maxAttempts = opts.maxAttempts || 3;
1145
- const logFn = opts.log || console.log;
1168
+ const logFn = opts.log || defaultLog;
1146
1169
  const errors = [];
1147
1170
 
1148
1171
  // If nodeAddress specified, try it first (skip circuit breaker check for explicit choice)
@@ -1433,6 +1456,38 @@ export async function connectViaSubscription(opts) {
1433
1456
 
1434
1457
  // ─── Shared Validation ───────────────────────────────────────────────────────
1435
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
+
1436
1491
  function validateConnectOpts(opts, fnName) {
1437
1492
  if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `${fnName}() requires an options object`);
1438
1493
  if (typeof opts.mnemonic !== 'string' || opts.mnemonic.trim().split(/\s+/).length < 12) {
@@ -1441,8 +1496,8 @@ function validateConnectOpts(opts, fnName) {
1441
1496
  if (typeof opts.nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(opts.nodeAddress)) {
1442
1497
  throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (47 characters)', { value: opts.nodeAddress });
1443
1498
  }
1444
- if (opts.rpcUrl != null && typeof opts.rpcUrl !== 'string') throw new ValidationError(ErrorCodes.INVALID_URL, 'rpcUrl must be a string URL', { value: opts.rpcUrl });
1445
- 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');
1446
1501
  }
1447
1502
 
1448
1503
  // ─── Shared Connect Flow (eliminates connectDirect/connectViaPlan duplication) ─
@@ -1460,8 +1515,10 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
1460
1515
  { nodeAddress: state.connection?.nodeAddress });
1461
1516
  }
1462
1517
  const prev = state.connection;
1463
- await disconnectState(state);
1464
- if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Disconnected from ${prev?.nodeAddress || 'previous node'}`);
1518
+ // Hard disconnect: user is actively connecting to a different node,
1519
+ // so the old session should be settled and the deposit refunded.
1520
+ await disconnectStateAndEndSession(state);
1521
+ if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Ended session on ${prev?.nodeAddress || 'previous node'} — connecting to new node`);
1465
1522
  }
1466
1523
 
1467
1524
  const onProgress = opts.onProgress || null;
@@ -1478,13 +1535,17 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
1478
1535
  // v21: parallelized — saves ~300ms (was sequential)
1479
1536
  progress(onProgress, logFn, 'wallet', 'Setting up wallet...');
1480
1537
  checkAborted(signal);
1481
- const [{ wallet, account }, privKey] = await Promise.all([
1538
+ const [walletResult, privKey] = await Promise.all([
1482
1539
  cachedCreateWallet(opts.mnemonic),
1483
1540
  privKeyFromMnemonic(opts.mnemonic),
1484
1541
  ]);
1542
+ const { wallet, account } = walletResult;
1485
1543
 
1486
- // Store mnemonic + feeGranter on state for session-end TX on disconnect (fire-and-forget cleanup)
1487
- 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;
1488
1549
  state._feeGranter = opts.feeGranter || null;
1489
1550
 
1490
1551
  // 2. RPC connect + LCD lookup in parallel (independent network calls)
@@ -1872,12 +1933,12 @@ async function setupWireGuard({ remoteUrl, sessionId, privKey, fullTunnel, split
1872
1933
  if (_killSwitchEnabled) disableKillSwitch();
1873
1934
  try { await disconnectWireGuard(); } catch {} // tunnel may already be down
1874
1935
  // End session on chain (fire-and-forget)
1875
- if (sessionIdStr && state._mnemonic) {
1876
- _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 }));
1877
1938
  }
1878
1939
  state.wgTunnel = null;
1879
1940
  state.connection = null;
1880
- state._mnemonic = null;
1941
+ state._wallet = null;
1881
1942
  clearState();
1882
1943
  },
1883
1944
  };
@@ -2136,11 +2197,11 @@ async function setupV2Ray({ remoteUrl, serverHost, sessionId, privKey, v2rayExeP
2136
2197
  if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
2137
2198
  if (state.systemProxy) clearSystemProxy();
2138
2199
  // End session on chain (fire-and-forget)
2139
- if (sessionIdStr && state._mnemonic) {
2140
- _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 }));
2141
2202
  }
2142
2203
  state.connection = null;
2143
- state._mnemonic = null;
2204
+ state._wallet = null;
2144
2205
  clearState();
2145
2206
  },
2146
2207
  };
@@ -2171,8 +2232,8 @@ export function getStatus() {
2171
2232
  const stale = _defaultState.connection;
2172
2233
  _defaultState.connection = null;
2173
2234
  // End session on chain (fire-and-forget) to prevent stale session leaks
2174
- if (stale?.sessionId && _defaultState._mnemonic) {
2175
- _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 }));
2176
2237
  }
2177
2238
  clearState();
2178
2239
  events.emit('disconnected', { nodeAddress: stale.nodeAddress, serviceType: stale.serviceType, reason: 'phantom_state' });
@@ -2218,8 +2279,8 @@ export function getStatus() {
2218
2279
  // clean up stale state. Prevents ghost "connected" status after tunnel dies.
2219
2280
  if (!healthChecks.tunnelActive && !_defaultState.v2rayProc && !_defaultState.wgTunnel) {
2220
2281
  // Both tunnel handles are null — connection state is stale
2221
- if (conn?.sessionId && _defaultState._mnemonic) {
2222
- _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 }));
2223
2284
  }
2224
2285
  _defaultState.connection = null;
2225
2286
  clearState();
@@ -2227,8 +2288,8 @@ export function getStatus() {
2227
2288
  }
2228
2289
  if (_defaultState.wgTunnel && !healthChecks.tunnelActive) {
2229
2290
  // WireGuard state says connected but tunnel is dead — auto-cleanup
2230
- if (conn?.sessionId && _defaultState._mnemonic) {
2231
- _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 }));
2232
2293
  }
2233
2294
  _defaultState.wgTunnel = null;
2234
2295
  _defaultState.connection = null;
@@ -2238,8 +2299,8 @@ export function getStatus() {
2238
2299
  }
2239
2300
  if (_defaultState.v2rayProc && !healthChecks.tunnelActive) {
2240
2301
  // V2Ray process died — auto-cleanup
2241
- if (conn?.sessionId && _defaultState._mnemonic) {
2242
- _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 }));
2243
2304
  }
2244
2305
  _defaultState.v2rayProc = null;
2245
2306
  _defaultState.connection = null;
@@ -2267,13 +2328,35 @@ function formatUptime(ms) {
2267
2328
  }
2268
2329
 
2269
2330
  // ─── Disconnect ──────────────────────────────────────────────────────────────
2331
+ //
2332
+ // TWO DISCONNECT PATHS — CHOOSE INTENT EXPLICITLY.
2333
+ //
2334
+ // Soft: disconnect() / disconnectState(state)
2335
+ // - Tears down the local tunnel (WireGuard/V2Ray) and cleans up system state.
2336
+ // - Leaves the on-chain session in status=1 (active).
2337
+ // - Next connectDirect() to the SAME node reuses the session via
2338
+ // findExistingSession — no new MsgStartSession TX, no new payment, bandwidth preserved.
2339
+ // - Use when: user is pausing, network changed, closing the app temporarily.
2340
+ //
2341
+ // Hard: disconnectAndEndSession() / disconnectStateAndEndSession(state)
2342
+ // - Tears down the tunnel AND broadcasts MsgCancelSession on chain.
2343
+ // - Session moves status=1 → settling → refund after ~2h settlement window.
2344
+ // - Use when: user is done with this node, switching nodes permanently,
2345
+ // or wants the bandwidth deposit back.
2346
+ //
2347
+ // Internal: _disconnectInternal(state, { endSession })
2348
+ // - Caller MUST pass endSession explicitly as true or false.
2349
+ // - No default — forces intentional choice at every callsite.
2270
2350
 
2271
2351
  /**
2272
- * Clean up all active tunnels and system proxy.
2273
- * ALWAYS call this on exit — a stale WireGuard tunnel will kill your internet.
2352
+ * Internal disconnect implementation. Caller must explicitly pass endSession.
2353
+ * @param {object} state - ConnectionState instance
2354
+ * @param {{ endSession: boolean }} opts
2355
+ * endSession: true → broadcast MsgCancelSession (hard disconnect)
2356
+ * endSession: false → preserve on-chain session for reuse (soft disconnect)
2357
+ * @private
2274
2358
  */
2275
- /** Disconnect a specific state instance (internal). */
2276
- export async function disconnectState(state) {
2359
+ async function _disconnectInternal(state, { endSession }) {
2277
2360
  // v30: Signal any running connectAuto() retry loop to abort, and release the
2278
2361
  // connection lock so the user can reconnect after disconnect completes.
2279
2362
  _abortConnect = true;
@@ -2299,15 +2382,22 @@ export async function disconnectState(state) {
2299
2382
  state.wgTunnel = null;
2300
2383
  }
2301
2384
 
2302
- // End session on chain (best-effort, fire-and-forget — never blocks disconnect)
2303
- if (prev?.sessionId && state._mnemonic) {
2304
- _endSessionOnChain(prev.sessionId, state._mnemonic, state._feeGranter).catch(e => {
2305
- console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
2306
- });
2385
+ if (endSession) {
2386
+ // Hard disconnect: broadcast MsgCancelSession — session settles and refunds deposit.
2387
+ if (prev?.sessionId && state._wallet) {
2388
+ _endSessionOnChain(prev.sessionId, state._wallet, state._feeGranter).catch(e => {
2389
+ console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
2390
+ });
2391
+ }
2392
+ } else {
2393
+ // Soft disconnect: leave session on chain in status=1 for reuse.
2394
+ if (prev?.sessionId) {
2395
+ console.log(`[sentinel-sdk] Session ${prev.sessionId} preserved on chain (status=1) for future reuse — no MsgCancelSession broadcast.`);
2396
+ }
2307
2397
  }
2308
2398
  } finally {
2309
2399
  // ALWAYS clear connection state — even if teardown threw
2310
- state._mnemonic = null;
2400
+ state._wallet = null;
2311
2401
  state._feeGranter = null;
2312
2402
  state.connection = null;
2313
2403
  clearState();
@@ -2317,8 +2407,55 @@ export async function disconnectState(state) {
2317
2407
  }
2318
2408
  }
2319
2409
 
2410
+ /**
2411
+ * Soft disconnect — tear down the local tunnel, leave the on-chain session active.
2412
+ *
2413
+ * A subsequent connectDirect() to the SAME node will reuse the session via
2414
+ * findExistingSession — no new MsgStartSession TX, no new payment, remaining
2415
+ * bandwidth is preserved.
2416
+ *
2417
+ * Use when: user is pausing, network changed, or closing the app temporarily.
2418
+ * To settle the session on-chain and reclaim the unused deposit, use
2419
+ * disconnectAndEndSession() instead.
2420
+ *
2421
+ * @param {object} state - ConnectionState instance
2422
+ */
2423
+ export async function disconnectState(state) {
2424
+ return _disconnectInternal(state, { endSession: false });
2425
+ }
2426
+
2427
+ /**
2428
+ * Soft disconnect (default state) — tear down the tunnel, leave on-chain session active.
2429
+ *
2430
+ * @see disconnectState
2431
+ */
2320
2432
  export async function disconnect() {
2321
- return disconnectState(_defaultState);
2433
+ return _disconnectInternal(_defaultState, { endSession: false });
2434
+ }
2435
+
2436
+ /**
2437
+ * Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
2438
+ *
2439
+ * The session settles after the ~2h inactive_pending window. The node refunds
2440
+ * the unused portion of the bandwidth deposit (for peer-to-peer sessions).
2441
+ * For plan-based sessions, this stops metering against the plan allocation.
2442
+ *
2443
+ * Use when: user is done with this node (switching nodes permanently, ending
2444
+ * their session, or wants the deposit back).
2445
+ *
2446
+ * @param {object} state - ConnectionState instance
2447
+ */
2448
+ export async function disconnectStateAndEndSession(state) {
2449
+ return _disconnectInternal(state, { endSession: true });
2450
+ }
2451
+
2452
+ /**
2453
+ * Hard disconnect (default state) — tear down the tunnel AND broadcast MsgCancelSession.
2454
+ *
2455
+ * @see disconnectStateAndEndSession
2456
+ */
2457
+ export async function disconnectAndEndSession() {
2458
+ return _disconnectInternal(_defaultState, { endSession: true });
2322
2459
  }
2323
2460
 
2324
2461
  // ─── Session End (on-chain cleanup) ──────────────────────────────────────────
@@ -2327,11 +2464,14 @@ export async function disconnect() {
2327
2464
  * End a session on-chain. Best-effort, fire-and-forget.
2328
2465
  * Prevents stale session accumulation on nodes.
2329
2466
  * @param {string|bigint} sessionId - Session ID to end
2330
- * @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
2331
2470
  * @private
2332
2471
  */
2333
- async function _endSessionOnChain(sessionId, mnemonic, feeGranter = null) {
2334
- 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;
2335
2475
  const client = await tryWithFallback(
2336
2476
  RPC_ENDPOINTS,
2337
2477
  async (url) => createClient(url, wallet),
@@ -2377,7 +2517,7 @@ export async function recoverSession(opts) {
2377
2517
  if (!opts?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'recoverSession requires opts.nodeAddress');
2378
2518
  if (!opts?.mnemonic) throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'recoverSession requires opts.mnemonic');
2379
2519
 
2380
- const logFn = opts.log || console.log;
2520
+ const logFn = opts.log || defaultLog;
2381
2521
  const onProgress = opts.onProgress || null;
2382
2522
  const sessionId = BigInt(opts.sessionId);
2383
2523
  const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };
package/operator.js CHANGED
@@ -77,8 +77,20 @@ export {
77
77
  grantPlanSubscribers,
78
78
  renewExpiringGrants,
79
79
  monitorFeeGrants,
80
+ streamGrantPlanSubscribers,
81
+ computeFeeGrantGasCosts,
80
82
  } from './cosmjs-setup.js';
81
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
+
82
94
  // ─── Authz ──────────────────────────────────────────────────────────────────
83
95
 
84
96
  export {
@@ -132,3 +144,17 @@ export {
132
144
  encodeMsgUpdateSubscription,
133
145
  encodeMsgUpdateSession,
134
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,13 +1,12 @@
1
1
  {
2
2
  "name": "blue-js-sdk",
3
- "version": "2.4.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",
10
- "sentinel-ai": "./ai-path/cli.js"
9
+ "sentinel": "cli/index.js"
11
10
  },
12
11
  "exports": {
13
12
  ".": {
@@ -16,7 +15,6 @@
16
15
  },
17
16
  "./consumer": "./consumer.js",
18
17
  "./operator": "./operator.js",
19
- "./ai-path": "./ai-path/index.js",
20
18
  "./blue": {
21
19
  "types": "./dist/index.d.ts",
22
20
  "default": "./dist/index.js"
@@ -49,7 +47,6 @@
49
47
  "errors/",
50
48
  "examples/",
51
49
  "docs/",
52
- "ai-path/",
53
50
  "bin/setup.js",
54
51
  "dist/",
55
52
  "src/",
@@ -66,6 +63,9 @@
66
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",
67
64
  "test": "node test/smoke.js",
68
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",
69
69
  "postinstall": "node bin/setup.js || true"
70
70
  },
71
71
  "keywords": [
@@ -89,18 +89,18 @@
89
89
  "license": "MIT",
90
90
  "repository": {
91
91
  "type": "git",
92
- "url": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk.git"
92
+ "url": "git+https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk.git"
93
93
  },
94
94
  "bugs": {
95
95
  "url": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk/issues"
96
96
  },
97
97
  "homepage": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk#readme",
98
98
  "dependencies": {
99
- "@cosmjs/amino": "0.32.2",
100
- "@cosmjs/crypto": "0.32.2",
101
- "@cosmjs/encoding": "0.32.2",
102
- "@cosmjs/proto-signing": "0.32.2",
103
- "@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",
104
104
  "@noble/curves": "^1.4.0",
105
105
  "axios": "^1.7.0",
106
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
+ }