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/chain/rpc.js CHANGED
@@ -59,6 +59,57 @@ export async function createRpcQueryClientWithFallback() {
59
59
  throw new Error(`All RPC endpoints failed: ${errors.map(e => `${e.url}: ${e.error}`).join('; ')}`);
60
60
  }
61
61
 
62
+ // ─── Failover with Per-Attempt Timeout ────────────────────────────────────
63
+
64
+ /**
65
+ * Race a promise against a timeout. The timer is unref()'d so it won't pin
66
+ * the event loop if the connect succeeds first.
67
+ */
68
+ function withTimeout(promise, ms, label) {
69
+ return Promise.race([
70
+ promise,
71
+ new Promise((_, rej) => {
72
+ const t = setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms);
73
+ if (typeof t?.unref === 'function') t.unref();
74
+ }),
75
+ ]);
76
+ }
77
+
78
+ /**
79
+ * Try RPC endpoints in order with a per-attempt timeout. Returns the first
80
+ * endpoint that connects AND passes a status health-check within the timeout.
81
+ *
82
+ * Fixes: Tendermint37Client.connect() never times out on a hung TCP handshake,
83
+ * leaving startup stalled for 30+ seconds against a Cloudflare-fronted RPC.
84
+ *
85
+ * @param {string[]} endpoints - RPC URLs to try in order
86
+ * @param {object} [opts]
87
+ * @param {number} [opts.perAttemptTimeoutMs=4000] - Timeout per endpoint (connect + status)
88
+ * @param {boolean} [opts.healthCheck=true] - If true, also races status() within timeout
89
+ * @returns {Promise<{ tmClient: Tendermint37Client, url: string }>}
90
+ */
91
+ export async function connectFailoverWithTimeout(endpoints, { perAttemptTimeoutMs = 4000, healthCheck = true } = {}) {
92
+ const errors = [];
93
+ for (const url of endpoints) {
94
+ try {
95
+ const tmClient = await withTimeout(
96
+ Tendermint37Client.connect(url),
97
+ perAttemptTimeoutMs,
98
+ `${url} connect`,
99
+ );
100
+ if (healthCheck) {
101
+ await withTimeout(tmClient.status(), perAttemptTimeoutMs, `${url} status`);
102
+ }
103
+ return { tmClient, url };
104
+ } catch (err) {
105
+ errors.push({ url, msg: err.message });
106
+ }
107
+ }
108
+ const e = new Error(`All RPC endpoints failed (timeout: ${perAttemptTimeoutMs}ms)`);
109
+ e.attempts = errors;
110
+ throw e;
111
+ }
112
+
62
113
  /**
63
114
  * Disconnect and clear cached RPC client.
64
115
  */
@@ -402,11 +453,17 @@ function decodeAllocation(fields) {
402
453
  /**
403
454
  * Query active nodes via RPC.
404
455
  *
456
+ * NOTE ON PAGINATION: Sentinel v3's `QueryNodes` truncates at the requested
457
+ * `limit` and does NOT emit `pagination.next_key`. A standard Cosmos
458
+ * "loop while next_key is non-empty" pattern terminates on the first call
459
+ * and silently loses data. Request above the chain's current hard ceiling
460
+ * (~1048 active nodes as of 2026-04). Default raised to 10000.
461
+ *
405
462
  * @param {{ queryClient: QueryClient }} client - From createRpcQueryClient()
406
463
  * @param {{ status?: number, limit?: number }} [opts]
407
464
  * @returns {Promise<Array<{ address: string, gigabyte_prices: Array, hourly_prices: Array, remote_addrs: string[], status: number }>>}
408
465
  */
409
- export async function rpcQueryNodes(client, { status = 1, limit = 500 } = {}) {
466
+ export async function rpcQueryNodes(client, { status = 1, limit = 10000 } = {}) {
410
467
  const path = '/sentinel.node.v3.QueryService/QueryNodes';
411
468
  const request = concat([
412
469
  encodeEnum(1, status), // status field
@@ -446,12 +503,18 @@ export async function rpcQueryNode(client, address) {
446
503
  /**
447
504
  * Query nodes linked to a plan via RPC.
448
505
  *
506
+ * NOTE ON PAGINATION: `QueryNodesForPlan` silently truncates at the requested
507
+ * `limit` with no `next_key`. Observed 2026-04: plan 36 has 803 active nodes
508
+ * but `limit=500` returns exactly 500 with no indication more exist. Default
509
+ * raised to 10000. If a plan grows beyond that, raise further — the chain's
510
+ * own ceiling is the effective limit.
511
+ *
449
512
  * @param {{ queryClient: QueryClient }} client
450
513
  * @param {number|bigint} planId
451
514
  * @param {{ status?: number, limit?: number }} [opts]
452
515
  * @returns {Promise<Array>}
453
516
  */
454
- export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit = 500 } = {}) {
517
+ export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit = 10000 } = {}) {
455
518
  const path = '/sentinel.node.v3.QueryService/QueryNodesForPlan';
456
519
  const request = concat([
457
520
  encodeUint64(1, planId), // id
@@ -768,8 +831,14 @@ export async function rpcQueryFeeGrantsIssued(client, granter, { limit = 100 } =
768
831
  const fields = decodeProto(new Uint8Array(response));
769
832
  // Field 1 = repeated Grant
770
833
  return (fields[1] || []).map(entry => _decodeFeeGrant(entry.value, null, granter));
771
- } catch {
772
- return [];
834
+ } catch (err) {
835
+ // Classify: "not found"/404 → treat as empty (genuinely no grants). Any
836
+ // other error (network, decode, timeout) should surface so the caller can
837
+ // distinguish "granter has no grants" from "RPC is broken" and fall back
838
+ // to LCD instead of silently returning [].
839
+ const msg = (err?.message || '').toLowerCase();
840
+ if (msg.includes('not found') || msg.includes('404')) return [];
841
+ throw err;
773
842
  }
774
843
  }
775
844
 
@@ -891,6 +960,50 @@ export async function rpcQueryProvider(client, provAddress) {
891
960
  }
892
961
  }
893
962
 
963
+ // ─── TX Hash Lookup ─────────────────────────────────────────────────────────
964
+
965
+ /**
966
+ * Fetch a transaction by hash via Tendermint RPC.
967
+ * Accepts bare hex (64 chars) or 0x-prefixed hex. Returns a normalized shape
968
+ * matching the LCD cosmos/tx/v1beta1/txs/{hash} response so callers don't
969
+ * need to handle both formats.
970
+ *
971
+ * @param {import('@cosmjs/tendermint-rpc').Tendermint37Client} tmClient - From createRpcQueryClient().tmClient
972
+ * @param {string} txHash - TX hash as hex string (bare or 0x-prefixed)
973
+ * @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string }>}
974
+ * @throws {Error} If the transaction is not found or RPC call fails
975
+ */
976
+ export async function rpcGetTxByHash(tmClient, txHash) {
977
+ // Strip optional 0x prefix and normalise to upper-case for consistency
978
+ const hex = txHash.replace(/^0x/i, '').toUpperCase();
979
+ // Decode hex string → Uint8Array
980
+ const hashBytes = new Uint8Array(hex.length / 2);
981
+ for (let i = 0; i < hex.length; i += 2) {
982
+ hashBytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
983
+ }
984
+
985
+ const response = await tmClient.tx({ hash: hashBytes });
986
+
987
+ // Normalise events: TxData.events is already decoded by CosmJS
988
+ const events = (response.result.events || []).map(ev => ({
989
+ type: ev.type,
990
+ attributes: (ev.attributes || []).map(attr => ({
991
+ key: attr.key,
992
+ value: attr.value,
993
+ })),
994
+ }));
995
+
996
+ return {
997
+ hash: hex,
998
+ height: response.height,
999
+ code: response.result.code,
1000
+ rawLog: response.result.log || '',
1001
+ events,
1002
+ gasUsed: String(response.result.gasUsed),
1003
+ gasWanted: String(response.result.gasWanted),
1004
+ };
1005
+ }
1006
+
894
1007
  export async function rpcQueryBalance(client, address, denom = 'udvpn') {
895
1008
  const path = '/cosmos.bank.v1beta1.Query/Balance';
896
1009
  const request = concat([
package/cli.js CHANGED
@@ -116,7 +116,7 @@ Sentinel SDK CLI — command-line tools for Sentinel dVPN
116
116
 
117
117
  WALLET
118
118
  balance Show wallet balance
119
- generate Generate new mnemonic + address
119
+ generate [--out <path>] Generate new mnemonic + address (mnemonic to stderr or 0600 file)
120
120
  address Show wallet address + provider address
121
121
 
122
122
  NODES
@@ -177,7 +177,9 @@ Options:
177
177
  --dns <preset> DNS preset: handshake (default), google, cloudflare, or custom IPs
178
178
 
179
179
  Environment:
180
- MNEMONIC BIP39 mnemonic phrase (in .env file)
180
+ MNEMONIC BIP39 mnemonic phrase. Prefer a 0600 file or OS keychain
181
+ over .env: .env files are world-readable to processes
182
+ running as the same user and are easy to commit by accident.
181
183
  `);
182
184
  },
183
185
 
@@ -192,9 +194,28 @@ Environment:
192
194
 
193
195
  async generate() {
194
196
  const { mnemonic, account } = await generateWallet();
195
- console.log(`Mnemonic: ${mnemonic}`);
196
- console.log(`Address: ${account.address}`);
197
- console.log(`\nSave the mnemonic in your .env file as MNEMONIC="..."`);
197
+ const out = flag('out', null);
198
+
199
+ if (out) {
200
+ const fs = await import('node:fs');
201
+ const path = await import('node:path');
202
+ const target = path.resolve(out);
203
+ fs.writeFileSync(target, mnemonic + '\n', { mode: 0o600 });
204
+ try { fs.chmodSync(target, 0o600); } catch {}
205
+ process.stdout.write(`Address: ${account.address}\n`);
206
+ process.stderr.write(`\nMnemonic written to ${target} (mode 0600).\n`);
207
+ process.stderr.write(`Store this file offline — anyone who reads it controls the wallet.\n`);
208
+ return;
209
+ }
210
+
211
+ // Interactive path: print to stderr (not stdout) so the phrase does not
212
+ // land in shell pipelines, redirected log files, or CI stdout capture.
213
+ // Address goes to stdout because callers pipe it into automation safely.
214
+ process.stdout.write(`Address: ${account.address}\n`);
215
+ process.stderr.write(`\n*** RECOVERY PHRASE — DO NOT LOG, DO NOT COMMIT ***\n`);
216
+ process.stderr.write(`Mnemonic: ${mnemonic}\n`);
217
+ process.stderr.write(`\nStore the phrase offline (hardware wallet, paper, encrypted vault).\n`);
218
+ process.stderr.write(`Pass --out <path> next time to write a 0600 file instead of printing.\n`);
198
219
  },
199
220
 
200
221
  async address() {
package/client.js CHANGED
@@ -28,6 +28,7 @@ import { EventEmitter } from 'events';
28
28
  import {
29
29
  connectDirect, connectViaPlan, connectAuto, queryOnlineNodes,
30
30
  disconnect as sdkDisconnect, disconnectState,
31
+ disconnectAndEndSession as sdkDisconnectAndEndSession, disconnectStateAndEndSession,
31
32
  isConnected as sdkIsConnected, getStatus as sdkGetStatus,
32
33
  registerCleanupHandlers, setSystemProxy, clearSystemProxy,
33
34
  events as sdkEvents, ConnectionState,
@@ -49,6 +50,10 @@ export class SentinelClient extends EventEmitter {
49
50
  * @param {string} opts.rpcUrl - Default RPC URL (overridable per-call)
50
51
  * @param {string} opts.lcdUrl - Default LCD URL (overridable per-call)
51
52
  * @param {string} opts.mnemonic - Default mnemonic (overridable per-call)
53
+ * @param {object} opts.signer - Pre-built cosmjs OfflineDirectSigner (e.g. from
54
+ * PrivyCosmosSigner.fromRawSign). When provided, takes precedence over `mnemonic`
55
+ * for queries and broadcasts. NOTE: VPN connect/disconnect (tunnel handshake)
56
+ * currently still requires a mnemonic — see docs/PRIVY-INTEGRATION.md.
52
57
  * @param {string} opts.v2rayExePath - Default V2Ray binary path
53
58
  * @param {function} opts.logger - Logger function (default: console.log). Set to null to suppress.
54
59
  * @param {'tofu'|'none'} opts.tlsTrust - TLS trust mode (default: 'tofu')
@@ -88,6 +93,21 @@ export class SentinelClient extends EventEmitter {
88
93
  return merged;
89
94
  }
90
95
 
96
+ /**
97
+ * Throw a helpful error if a connect path is invoked without a mnemonic.
98
+ * The WireGuard/V2Ray handshake currently signs locally with the cosmos privkey,
99
+ * so a raw-sign-only signer (e.g. Privy custody) cannot complete the tunnel
100
+ * handshake. Queries and broadcasts work without a mnemonic.
101
+ */
102
+ _requireMnemonicForTunnel(merged) {
103
+ if (typeof merged.mnemonic === 'string' && merged.mnemonic.trim().length > 0) return;
104
+ throw new SentinelError(ErrorCodes.INVALID_MNEMONIC,
105
+ 'VPN connect/disconnect requires a mnemonic. A signer-only client (e.g. ' +
106
+ 'Privy raw-sign Mode B) can broadcast TXs and query chain state, but the ' +
107
+ 'tunnel handshake signs with the raw secp256k1 privkey. Pass `mnemonic` to ' +
108
+ 'the constructor or to this call. See docs/PRIVY-INTEGRATION.md.');
109
+ }
110
+
91
111
  // ─── Connection ──────────────────────────────────────────────────────────
92
112
 
93
113
  /**
@@ -97,6 +117,7 @@ export class SentinelClient extends EventEmitter {
97
117
  */
98
118
  async connect(opts = {}) {
99
119
  const merged = this._mergeOpts(opts);
120
+ this._requireMnemonicForTunnel(merged);
100
121
  this._connection = await connectDirect(merged);
101
122
  return this._connection;
102
123
  }
@@ -109,6 +130,7 @@ export class SentinelClient extends EventEmitter {
109
130
  */
110
131
  async autoConnect(opts = {}) {
111
132
  const merged = this._mergeOpts(opts);
133
+ this._requireMnemonicForTunnel(merged);
112
134
  this._connection = await connectAuto(merged);
113
135
  return this._connection;
114
136
  }
@@ -120,18 +142,34 @@ export class SentinelClient extends EventEmitter {
120
142
  */
121
143
  async connectPlan(opts = {}) {
122
144
  const merged = this._mergeOpts(opts);
145
+ this._requireMnemonicForTunnel(merged);
123
146
  this._connection = await connectViaPlan(merged);
124
147
  return this._connection;
125
148
  }
126
149
 
127
150
  /**
128
- * Disconnect current VPN tunnel.
151
+ * Soft disconnect tear down the tunnel, leave the on-chain session active.
152
+ *
153
+ * A subsequent connect() to the SAME node reuses the session (no new payment).
154
+ * Use for pause / temporary disconnect / network-change recovery.
155
+ * To settle the session and reclaim the deposit, use disconnectAndEndSession().
129
156
  */
130
157
  async disconnect() {
131
158
  await disconnectState(this._state);
132
159
  this._connection = null;
133
160
  }
134
161
 
162
+ /**
163
+ * Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
164
+ *
165
+ * Settles the session after the ~2h window and refunds the unused deposit.
166
+ * Use when the user is done with this node (switching permanently or wants refund).
167
+ */
168
+ async disconnectAndEndSession() {
169
+ await disconnectStateAndEndSession(this._state);
170
+ this._connection = null;
171
+ }
172
+
135
173
  /**
136
174
  * Check if a VPN tunnel is currently active.
137
175
  */
@@ -193,16 +231,50 @@ export class SentinelClient extends EventEmitter {
193
231
  // ─── Wallet & Chain ──────────────────────────────────────────────────────
194
232
 
195
233
  /**
196
- * Create or return cached wallet from mnemonic.
197
- * @param {string} mnemonic - Override mnemonic (or uses instance default)
234
+ * Create or return cached wallet/signer.
235
+ *
236
+ * Resolution order:
237
+ * 1. `mnemonic` arg (per-call override) → derive a DirectSecp256k1HdWallet
238
+ * 2. `this._defaults.signer` (constructor-supplied OfflineDirectSigner) → use as-is
239
+ * 3. `this._defaults.mnemonic` → derive once, cache by mnemonic SHA
240
+ *
241
+ * Returned shape: `{ wallet, account }` — `wallet` is a cosmjs OfflineDirectSigner
242
+ * (DirectSecp256k1HdWallet OR a PrivyRawSignDirectSigner OR any equivalent), and
243
+ * `account` is the first entry from `wallet.getAccounts()`.
244
+ *
245
+ * @param {string} [mnemonic] - Optional per-call mnemonic override
198
246
  */
199
247
  async getWallet(mnemonic) {
200
- const m = mnemonic || this._defaults.mnemonic;
201
- if (!m) throw new SentinelError(ErrorCodes.INVALID_MNEMONIC, 'No mnemonic provided');
202
- // Invalidate cache if mnemonic changed
248
+ // Per-call mnemonic always wins (override path).
249
+ if (mnemonic) {
250
+ if (this._wallet && this._walletMnemonic !== mnemonic) {
251
+ this._wallet = null;
252
+ this._client = null;
253
+ }
254
+ if (this._wallet) return this._wallet;
255
+ this._wallet = await createWallet(mnemonic);
256
+ this._walletMnemonic = mnemonic;
257
+ return this._wallet;
258
+ }
259
+ // Constructor-supplied signer (Privy raw-sign, Keplr offline signer, etc.).
260
+ if (this._defaults.signer) {
261
+ if (this._wallet) return this._wallet;
262
+ const accounts = await this._defaults.signer.getAccounts();
263
+ if (!accounts || accounts.length === 0) {
264
+ throw new SentinelError(ErrorCodes.INVALID_OPTIONS,
265
+ 'signer.getAccounts() returned no accounts');
266
+ }
267
+ this._wallet = { wallet: this._defaults.signer, account: accounts[0] };
268
+ this._walletMnemonic = null;
269
+ return this._wallet;
270
+ }
271
+ // Constructor-supplied mnemonic (the original path).
272
+ const m = this._defaults.mnemonic;
273
+ if (!m) throw new SentinelError(ErrorCodes.INVALID_MNEMONIC,
274
+ 'No mnemonic or signer provided. Pass `mnemonic` or `signer` to the SentinelClient constructor.');
203
275
  if (this._wallet && this._walletMnemonic !== m) {
204
276
  this._wallet = null;
205
- this._client = null; // client depends on wallet
277
+ this._client = null;
206
278
  }
207
279
  if (this._wallet) return this._wallet;
208
280
  this._wallet = await createWallet(m);
@@ -9,13 +9,13 @@ import {
9
9
  events, _defaultState, progress, checkAborted,
10
10
  warnIfNoCleanup, cachedCreateWallet, _recordMetric,
11
11
  broadcastWithInactiveRetry, getConnectLock, setConnectLock,
12
- getAbortConnect, setAbortConnect,
12
+ getAbortConnect, setAbortConnect, _endSessionOnChain,
13
13
  } from './state.js';
14
14
 
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 {
@@ -31,7 +31,7 @@ import {
31
31
  import { createNodeHttpsAgent } from '../tls-trust.js';
32
32
  import { disconnectWireGuard } from '../wireguard.js';
33
33
 
34
- import { disconnectState } from './disconnect.js';
34
+ import { disconnectState, disconnectStateAndEndSession } from './disconnect.js';
35
35
  import { queryOnlineNodes } from './discovery.js';
36
36
  import {
37
37
  recordNodeFailure, isCircuitOpen, configureCircuitBreaker,
@@ -40,11 +40,51 @@ import {
40
40
  import { performHandshake, validateTunnelRequirements, killV2RayProc, verifyDependencies } from './tunnel.js';
41
41
  import { verifyConnection } from './state.js';
42
42
  import { registerCleanupHandlers } from './disconnect.js';
43
+ import { withMnemonicRedaction } from './logger.js';
43
44
 
44
- let defaultLog = console.log;
45
+ // Default logger wraps console.log with a mnemonic redactor. Anything that
46
+ // resembles a 12–24 word BIP-39 phrase in a log argument is replaced with
47
+ // `[REDACTED MNEMONIC]` before it reaches stdout. Defense-in-depth — the SDK
48
+ // does not currently log the mnemonic, but a future template-string bug or
49
+ // careless `JSON.stringify(opts)` won't leak it through the default logger.
50
+ let defaultLog = withMnemonicRedaction(console.log);
45
51
 
46
52
  // ─── Shared Validation ───────────────────────────────────────────────────────
47
53
 
54
+ /**
55
+ * Validate a chain endpoint URL. Enforces https:// to prevent attackers from
56
+ * coercing the SDK into broadcasting signed TXs over cleartext HTTP — a passive
57
+ * network observer on http://attacker.example/rpc would see the full TX body
58
+ * and could correlate it to the user's wallet address. Localhost is exempted
59
+ * for local-node development.
60
+ *
61
+ * @param {*} url - Value to validate (allowed: undefined/null, or string)
62
+ * @param {string} fieldName - 'rpcUrl' or 'lcdUrl', used in error messages
63
+ */
64
+ function validateChainUrl(url, fieldName) {
65
+ if (url == null) return;
66
+ if (typeof url !== 'string') {
67
+ throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must be a string URL`, { value: url });
68
+ }
69
+ let parsed;
70
+ try { parsed = new URL(url); }
71
+ catch { throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} is not a valid URL`, { value: url }); }
72
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
73
+ throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must use http:// or https:// (got ${parsed.protocol})`, { value: url });
74
+ }
75
+ const isLocalhost = parsed.hostname === 'localhost'
76
+ || parsed.hostname === '127.0.0.1'
77
+ || parsed.hostname === '::1'
78
+ || parsed.hostname === '[::1]';
79
+ if (parsed.protocol === 'http:' && !isLocalhost) {
80
+ throw new ValidationError(
81
+ ErrorCodes.INVALID_URL,
82
+ `${fieldName} must use https:// for non-localhost endpoints. Cleartext HTTP leaks signed TXs and queries to network observers (got ${url}).`,
83
+ { value: url },
84
+ );
85
+ }
86
+ }
87
+
48
88
  function validateConnectOpts(opts, fnName) {
49
89
  if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `${fnName}() requires an options object`);
50
90
  if (typeof opts.mnemonic !== 'string') {
@@ -60,8 +100,8 @@ function validateConnectOpts(opts, fnName) {
60
100
  if (typeof opts.nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(opts.nodeAddress)) {
61
101
  throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (47 characters)', { value: opts.nodeAddress });
62
102
  }
63
- if (opts.rpcUrl != null && typeof opts.rpcUrl !== 'string') throw new ValidationError(ErrorCodes.INVALID_URL, 'rpcUrl must be a string URL', { value: opts.rpcUrl });
64
- if (opts.lcdUrl != null && typeof opts.lcdUrl !== 'string') throw new ValidationError(ErrorCodes.INVALID_URL, 'lcdUrl must be a string URL', { value: opts.lcdUrl });
103
+ validateChainUrl(opts.rpcUrl, 'rpcUrl');
104
+ validateChainUrl(opts.lcdUrl, 'lcdUrl');
65
105
  }
66
106
 
67
107
  // ─── Shared Connect Flow (eliminates connectDirect/connectViaPlan duplication) ─
@@ -79,8 +119,10 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
79
119
  { nodeAddress: state.connection?.nodeAddress });
80
120
  }
81
121
  const prev = state.connection;
82
- await disconnectState(state);
83
- if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Disconnected from ${prev?.nodeAddress || 'previous node'}`);
122
+ // Hard disconnect: user is actively connecting to a different node,
123
+ // so the old session should be settled and the deposit refunded.
124
+ await disconnectStateAndEndSession(state);
125
+ if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Ended session on ${prev?.nodeAddress || 'previous node'} — connecting to new node`);
84
126
  }
85
127
 
86
128
  const onProgress = opts.onProgress || null;
@@ -97,13 +139,17 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
97
139
  // v21: parallelized — saves ~300ms (was sequential)
98
140
  progress(onProgress, logFn, 'wallet', 'Setting up wallet...');
99
141
  checkAborted(signal);
100
- const [{ wallet, account }, privKey] = await Promise.all([
142
+ const [walletResult, privKey] = await Promise.all([
101
143
  cachedCreateWallet(opts.mnemonic),
102
144
  privKeyFromMnemonic(opts.mnemonic),
103
145
  ]);
146
+ const { wallet, account } = walletResult;
104
147
 
105
- // Store mnemonic on state for session-end TX on disconnect (fire-and-forget cleanup)
106
- state._mnemonic = opts.mnemonic;
148
+ // v37 (security): store the derived OfflineSigner NOT the BIP-39 mnemonic.
149
+ // _endSessionOnChain only needs to sign one TX on disconnect; it doesn't need
150
+ // the recovery phrase. Holding the mnemonic in heap for the full session
151
+ // expanded the heap-dump attack surface unnecessarily.
152
+ state._wallet = walletResult;
107
153
 
108
154
  // 2. RPC connect + LCD lookup in parallel (independent network calls)
109
155
  // v21: parallelized — saves 1-3s (was sequential)
@@ -379,9 +425,12 @@ export async function connectDirect(opts) {
379
425
 
380
426
  // ── Fast Reconnect: check for saved credentials ──
381
427
  if (!forceNewSession) {
382
- // Set mnemonic on state BEFORE fast reconnect needed for _endSessionOnChain() on disconnect
383
- (opts._state || _defaultState)._mnemonic = opts.mnemonic;
384
- const fast = await tryFastReconnect(opts, opts._state || _defaultState);
428
+ // v37: Derive wallet BEFORE fast reconnect and stash on state — _endSessionOnChain()
429
+ // needs the signer, not the mnemonic. Same wallet object is reused by connectInternal()
430
+ // below if fast reconnect misses (cachedCreateWallet keys on mnemonic SHA256).
431
+ const fastState = opts._state || _defaultState;
432
+ fastState._wallet = await cachedCreateWallet(opts.mnemonic);
433
+ const fast = await tryFastReconnect(opts, fastState);
385
434
  if (fast) {
386
435
  clearCircuitBreaker(opts.nodeAddress);
387
436
  return fast;
@@ -397,7 +446,20 @@ export async function connectDirect(opts) {
397
446
  if (!forceNewSession) {
398
447
  progress(onProgress, logFn, 'session', 'Checking for existing session...');
399
448
  checkAborted(signal);
400
- sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress);
449
+ sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress, {
450
+ // Dedup: if multiple active sessions exist for this node (stale duplicates from
451
+ // crashes or multi-client wallets), keep the highest-ID one. Silently cancel stale
452
+ // lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
453
+ onStaleDuplicate: (staleId) => {
454
+ logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
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 => {
459
+ logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
460
+ });
461
+ },
462
+ });
401
463
  if (sessionId && isSessionPoisoned(String(sessionId))) {
402
464
  progress(onProgress, logFn, 'session', `Session ${sessionId} previously failed — skipping`);
403
465
  sessionId = null;
@@ -536,7 +598,7 @@ export async function connectAuto(opts) {
536
598
  if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker);
537
599
 
538
600
  const maxAttempts = opts.maxAttempts || 3;
539
- const logFn = opts.log || console.log;
601
+ const logFn = opts.log || defaultLog;
540
602
  const errors = [];
541
603
 
542
604
  // If nodeAddress specified, try it first (skip circuit breaker check for explicit choice)
@@ -689,6 +751,22 @@ export async function connectViaPlan(opts) {
689
751
  // Fee grant: the app passes the plan owner's address as feeGranter.
690
752
  const feeGranter = opts.feeGranter || null;
691
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
+
692
770
  progress(null, opts.log || defaultLog, 'session', `Subscribing to plan ${opts.planId} + starting session${feeGranter ? ' (fee granted)' : ''}...`);
693
771
 
694
772
  let result;
@@ -696,8 +774,10 @@ export async function connectViaPlan(opts) {
696
774
  try {
697
775
  result = await broadcastWithFeeGrant(client, account.address, [msg], feeGranter);
698
776
  } catch (feeErr) {
699
- // Fee grant TX failed — fall back to user-paid
700
- progress(null, opts.log || defaultLog, 'session', 'Fee grant failed, paying gas from wallet...');
777
+ if (opts.requireFeeGrant === true) throw feeErr;
778
+ // Fee grant TX failed fall back to user-paid (default behavior)
779
+ const reason = feeErr?.message ? `: ${feeErr.message}` : '';
780
+ progress(null, opts.log || defaultLog, 'session', `Fee grant failed${reason}, paying gas from wallet...`);
701
781
  result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
702
782
  }
703
783
  } else {
@@ -758,7 +838,7 @@ export async function connectViaSubscription(opts) {
758
838
  try {
759
839
 
760
840
  async function subPayment(ctx) {
761
- const { client, account, logFn, onProgress, signal } = ctx;
841
+ const { client, account, lcd: lcdUrl, logFn, onProgress, signal } = ctx;
762
842
  const msg = {
763
843
  typeUrl: MSG_TYPES.SUB_START_SESSION,
764
844
  value: {
@@ -772,6 +852,22 @@ export async function connectViaSubscription(opts) {
772
852
 
773
853
  // Fee grant: operator pays gas for the agent (e.g., x402 managed plan flow)
774
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
+
775
871
  progress(null, opts.log || defaultLog, 'session', `Starting session via subscription ${opts.subscriptionId}${feeGranter ? ' (fee granted)' : ''}...`);
776
872
 
777
873
  let result;
@@ -779,8 +875,10 @@ export async function connectViaSubscription(opts) {
779
875
  try {
780
876
  result = await broadcastWithFeeGrant(client, account.address, [msg], feeGranter);
781
877
  } catch (feeErr) {
782
- // Fee grant TX failed — fall back to user-paid
783
- progress(null, opts.log || defaultLog, 'session', 'Fee grant failed, paying gas from wallet...');
878
+ if (opts.requireFeeGrant === true) throw feeErr;
879
+ // Fee grant TX failed fall back to user-paid (default behavior)
880
+ const reason = feeErr?.message ? `: ${feeErr.message}` : '';
881
+ progress(null, opts.log || defaultLog, 'session', `Fee grant failed${reason}, paying gas from wallet...`);
784
882
  result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
785
883
  }
786
884
  } else {