blue-js-sdk 2.0.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 (215) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/ai-path/ADMIN-ELEVATION.md +116 -0
  5. package/ai-path/AI-MANIFESTO.md +185 -0
  6. package/ai-path/BREAKING.md +74 -0
  7. package/ai-path/CHECKLIST.md +619 -0
  8. package/ai-path/CONNECTION-STEPS.md +724 -0
  9. package/ai-path/DECISION-TREE.md +378 -0
  10. package/ai-path/DEPENDENCIES.md +459 -0
  11. package/ai-path/E2E-FLOW.md +1555 -0
  12. package/ai-path/FAILURES.md +403 -0
  13. package/ai-path/GUIDE.md +1217 -0
  14. package/ai-path/README.md +558 -0
  15. package/ai-path/SPLIT-TUNNEL.md +266 -0
  16. package/ai-path/cli.js +535 -0
  17. package/ai-path/connect.js +884 -0
  18. package/ai-path/discover.js +178 -0
  19. package/ai-path/environment.js +266 -0
  20. package/ai-path/errors.js +86 -0
  21. package/ai-path/examples/autonomous-agent.mjs +220 -0
  22. package/ai-path/examples/multi-region.mjs +174 -0
  23. package/ai-path/examples/one-shot.mjs +31 -0
  24. package/ai-path/index.js +60 -0
  25. package/ai-path/pricing.js +136 -0
  26. package/ai-path/recommend.js +413 -0
  27. package/ai-path/run-admin.vbs +25 -0
  28. package/ai-path/setup.js +291 -0
  29. package/ai-path/wallet.js +137 -0
  30. package/app-helpers.js +363 -0
  31. package/app-settings.js +95 -0
  32. package/app-types.js +267 -0
  33. package/audit.js +847 -0
  34. package/batch.js +293 -0
  35. package/bin/setup.js +376 -0
  36. package/chain/authz.js +109 -0
  37. package/chain/broadcast.js +472 -0
  38. package/chain/client.js +160 -0
  39. package/chain/fee-grants.js +305 -0
  40. package/chain/index.js +891 -0
  41. package/chain/lcd.js +313 -0
  42. package/chain/queries.js +547 -0
  43. package/chain/rpc.js +408 -0
  44. package/chain/wallet.js +141 -0
  45. package/cli/config.js +143 -0
  46. package/cli/index.js +463 -0
  47. package/cli/output.js +182 -0
  48. package/cli.js +491 -0
  49. package/client/index.js +251 -0
  50. package/client.js +271 -0
  51. package/config/index.js +255 -0
  52. package/connection/connect.js +849 -0
  53. package/connection/disconnect.js +180 -0
  54. package/connection/discovery.js +321 -0
  55. package/connection/index.js +76 -0
  56. package/connection/proxy.js +148 -0
  57. package/connection/resilience.js +428 -0
  58. package/connection/security.js +232 -0
  59. package/connection/state.js +369 -0
  60. package/connection/tunnel.js +691 -0
  61. package/consumer.js +132 -0
  62. package/cosmjs-setup.js +1884 -0
  63. package/defaults.js +366 -0
  64. package/disk-cache.js +107 -0
  65. package/dist/client.d.ts +108 -0
  66. package/dist/client.d.ts.map +1 -0
  67. package/dist/client.js +400 -0
  68. package/dist/client.js.map +1 -0
  69. package/dist/index.d.ts +8 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +8 -0
  72. package/dist/index.js.map +1 -0
  73. package/errors/index.js +112 -0
  74. package/errors.js +218 -0
  75. package/examples/README.md +64 -0
  76. package/examples/connect-direct.mjs +106 -0
  77. package/examples/connect-plan.mjs +125 -0
  78. package/examples/error-handling.mjs +109 -0
  79. package/examples/query-nodes.mjs +94 -0
  80. package/examples/wallet-basics.mjs +61 -0
  81. package/generated/amino/amino.ts +9 -0
  82. package/generated/cosmos/base/v1beta1/coin.ts +365 -0
  83. package/generated/cosmos_proto/cosmos.ts +323 -0
  84. package/generated/gogoproto/gogo.ts +9 -0
  85. package/generated/google/protobuf/descriptor.ts +7601 -0
  86. package/generated/google/protobuf/duration.ts +208 -0
  87. package/generated/google/protobuf/timestamp.ts +238 -0
  88. package/generated/sentinel/lease/v1/events.ts +924 -0
  89. package/generated/sentinel/lease/v1/lease.ts +292 -0
  90. package/generated/sentinel/lease/v1/msg.ts +949 -0
  91. package/generated/sentinel/lease/v1/params.ts +164 -0
  92. package/generated/sentinel/node/v3/events.ts +881 -0
  93. package/generated/sentinel/node/v3/msg.ts +1002 -0
  94. package/generated/sentinel/node/v3/node.ts +263 -0
  95. package/generated/sentinel/node/v3/params.ts +183 -0
  96. package/generated/sentinel/plan/v3/events.ts +675 -0
  97. package/generated/sentinel/plan/v3/msg.ts +1191 -0
  98. package/generated/sentinel/plan/v3/plan.ts +283 -0
  99. package/generated/sentinel/provider/v2/events.ts +171 -0
  100. package/generated/sentinel/provider/v2/msg.ts +480 -0
  101. package/generated/sentinel/provider/v2/params.ts +131 -0
  102. package/generated/sentinel/provider/v2/provider.ts +246 -0
  103. package/generated/sentinel/session/v3/events.ts +480 -0
  104. package/generated/sentinel/session/v3/msg.ts +616 -0
  105. package/generated/sentinel/session/v3/params.ts +260 -0
  106. package/generated/sentinel/session/v3/proof.ts +180 -0
  107. package/generated/sentinel/session/v3/session.ts +384 -0
  108. package/generated/sentinel/subscription/v3/events.ts +1181 -0
  109. package/generated/sentinel/subscription/v3/msg.ts +1305 -0
  110. package/generated/sentinel/subscription/v3/params.ts +167 -0
  111. package/generated/sentinel/subscription/v3/subscription.ts +315 -0
  112. package/generated/sentinel/types/v1/bandwidth.ts +124 -0
  113. package/generated/sentinel/types/v1/price.ts +149 -0
  114. package/generated/sentinel/types/v1/renewal.ts +87 -0
  115. package/generated/sentinel/types/v1/status.ts +54 -0
  116. package/generated/typeRegistry.ts +27 -0
  117. package/index.js +486 -0
  118. package/node-connect.js +3015 -0
  119. package/operator.js +134 -0
  120. package/package.json +113 -0
  121. package/plan-operations.js +199 -0
  122. package/preflight.js +352 -0
  123. package/pricing/index.js +262 -0
  124. package/proto/amino/amino.proto +84 -0
  125. package/proto/cosmos/base/v1beta1/coin.proto +61 -0
  126. package/proto/cosmos_proto/cosmos.proto +112 -0
  127. package/proto/gogoproto/gogo.proto +145 -0
  128. package/proto/google/api/annotations.proto +31 -0
  129. package/proto/google/api/http.proto +370 -0
  130. package/proto/google/protobuf/any.proto +106 -0
  131. package/proto/google/protobuf/duration.proto +115 -0
  132. package/proto/google/protobuf/timestamp.proto +145 -0
  133. package/proto/sentinel/lease/v1/events.proto +52 -0
  134. package/proto/sentinel/lease/v1/genesis.proto +15 -0
  135. package/proto/sentinel/lease/v1/lease.proto +25 -0
  136. package/proto/sentinel/lease/v1/msg.proto +62 -0
  137. package/proto/sentinel/lease/v1/params.proto +17 -0
  138. package/proto/sentinel/node/v3/events.proto +50 -0
  139. package/proto/sentinel/node/v3/genesis.proto +15 -0
  140. package/proto/sentinel/node/v3/msg.proto +63 -0
  141. package/proto/sentinel/node/v3/node.proto +27 -0
  142. package/proto/sentinel/node/v3/params.proto +21 -0
  143. package/proto/sentinel/node/v3/querier.proto +63 -0
  144. package/proto/sentinel/plan/v3/events.proto +41 -0
  145. package/proto/sentinel/plan/v3/genesis.proto +21 -0
  146. package/proto/sentinel/plan/v3/msg.proto +83 -0
  147. package/proto/sentinel/plan/v3/plan.proto +32 -0
  148. package/proto/sentinel/plan/v3/querier.proto +53 -0
  149. package/proto/sentinel/provider/v2/events.proto +16 -0
  150. package/proto/sentinel/provider/v2/genesis.proto +15 -0
  151. package/proto/sentinel/provider/v2/msg.proto +35 -0
  152. package/proto/sentinel/provider/v2/params.proto +17 -0
  153. package/proto/sentinel/provider/v2/provider.proto +24 -0
  154. package/proto/sentinel/provider/v3/genesis.proto +15 -0
  155. package/proto/sentinel/provider/v3/params.proto +13 -0
  156. package/proto/sentinel/session/v3/events.proto +30 -0
  157. package/proto/sentinel/session/v3/genesis.proto +15 -0
  158. package/proto/sentinel/session/v3/msg.proto +50 -0
  159. package/proto/sentinel/session/v3/params.proto +25 -0
  160. package/proto/sentinel/session/v3/proof.proto +25 -0
  161. package/proto/sentinel/session/v3/querier.proto +100 -0
  162. package/proto/sentinel/session/v3/session.proto +50 -0
  163. package/proto/sentinel/subscription/v2/allocation.proto +21 -0
  164. package/proto/sentinel/subscription/v2/payout.proto +22 -0
  165. package/proto/sentinel/subscription/v3/events.proto +65 -0
  166. package/proto/sentinel/subscription/v3/genesis.proto +17 -0
  167. package/proto/sentinel/subscription/v3/msg.proto +83 -0
  168. package/proto/sentinel/subscription/v3/params.proto +21 -0
  169. package/proto/sentinel/subscription/v3/subscription.proto +33 -0
  170. package/proto/sentinel/types/v1/bandwidth.proto +19 -0
  171. package/proto/sentinel/types/v1/price.proto +21 -0
  172. package/proto/sentinel/types/v1/renewal.proto +21 -0
  173. package/proto/sentinel/types/v1/status.proto +16 -0
  174. package/protocol/encoding.js +341 -0
  175. package/protocol/events.js +361 -0
  176. package/protocol/handshake.js +297 -0
  177. package/protocol/index.js +15 -0
  178. package/protocol/messages.js +346 -0
  179. package/protocol/plans.js +199 -0
  180. package/protocol/v2ray.js +268 -0
  181. package/protocol/v3.js +723 -0
  182. package/protocol/wireguard.js +125 -0
  183. package/security/index.js +132 -0
  184. package/session-manager.js +329 -0
  185. package/session-tracker.js +80 -0
  186. package/setup.js +376 -0
  187. package/speedtest/index.js +528 -0
  188. package/speedtest.js +567 -0
  189. package/src/client.ts +502 -0
  190. package/src/index.ts +20 -0
  191. package/state/index.js +347 -0
  192. package/state.js +516 -0
  193. package/test-all-chain-ops.js +493 -0
  194. package/test-all-logic.js +199 -0
  195. package/test-all-msg-types.js +292 -0
  196. package/test-every-connection.js +208 -0
  197. package/test-feegrant-connect.js +98 -0
  198. package/test-logic.js +148 -0
  199. package/test-mainnet.js +176 -0
  200. package/test-plan-lifecycle.js +335 -0
  201. package/tls-trust.js +132 -0
  202. package/tsconfig.build.json +20 -0
  203. package/tsconfig.json +34 -0
  204. package/types/chain.d.ts +746 -0
  205. package/types/connection.d.ts +425 -0
  206. package/types/errors.d.ts +174 -0
  207. package/types/index.d.ts +1380 -0
  208. package/types/nodes.d.ts +187 -0
  209. package/types/pricing.d.ts +156 -0
  210. package/types/protocol.d.ts +332 -0
  211. package/types/session.d.ts +236 -0
  212. package/types/settings.d.ts +192 -0
  213. package/v3protocol.js +1053 -0
  214. package/wallet/index.js +153 -0
  215. package/wireguard.js +307 -0
@@ -0,0 +1,849 @@
1
+ /**
2
+ * Connection Orchestration — connectDirect, connectAuto, connectViaPlan,
3
+ * connectViaSubscription, quickConnect, createConnectConfig.
4
+ *
5
+ * Core connection flows that handle payment, handshake, and tunnel setup.
6
+ */
7
+
8
+ import {
9
+ events, _defaultState, progress, checkAborted,
10
+ warnIfNoCleanup, cachedCreateWallet, _recordMetric,
11
+ broadcastWithInactiveRetry, getConnectLock, setConnectLock,
12
+ getAbortConnect, setAbortConnect,
13
+ } from './state.js';
14
+
15
+ import {
16
+ createClient, privKeyFromMnemonic, broadcastWithFeeGrant,
17
+ extractId, findExistingSession, getBalance, MSG_TYPES, queryNode,
18
+ isMnemonicValid, filterNodes,
19
+ } from '../cosmjs-setup.js';
20
+ import { nodeStatusV3, waitForPort } from '../v3protocol.js';
21
+ import {
22
+ saveState, clearState, markSessionPoisoned, markSessionActive, isSessionPoisoned,
23
+ } from '../state.js';
24
+ import {
25
+ DEFAULT_RPC, DEFAULT_LCD, RPC_ENDPOINTS, LCD_ENDPOINTS,
26
+ DEFAULT_TIMEOUTS, sleep, tryWithFallback,
27
+ } from '../defaults.js';
28
+ import {
29
+ SentinelError, ValidationError, NodeError, ChainError, TunnelError, ErrorCodes,
30
+ } from '../errors.js';
31
+ import { createNodeHttpsAgent } from '../tls-trust.js';
32
+ import { disconnectWireGuard } from '../wireguard.js';
33
+
34
+ import { disconnectState } from './disconnect.js';
35
+ import { queryOnlineNodes } from './discovery.js';
36
+ import {
37
+ recordNodeFailure, isCircuitOpen, configureCircuitBreaker,
38
+ clearCircuitBreaker, tryFastReconnect,
39
+ } from './resilience.js';
40
+ import { performHandshake, validateTunnelRequirements, killV2RayProc, verifyDependencies } from './tunnel.js';
41
+ import { verifyConnection } from './state.js';
42
+ import { registerCleanupHandlers } from './disconnect.js';
43
+
44
+ let defaultLog = console.log;
45
+
46
+ // ─── Shared Validation ───────────────────────────────────────────────────────
47
+
48
+ function validateConnectOpts(opts, fnName) {
49
+ if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `${fnName}() requires an options object`);
50
+ if (typeof opts.mnemonic !== 'string') {
51
+ throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'mnemonic must be a string', { wordCount: 0 });
52
+ }
53
+ const words = opts.mnemonic.trim().split(/\s+/);
54
+ if (words.length < 12) {
55
+ throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'mnemonic must have at least 12 words', { wordCount: words.length });
56
+ }
57
+ if (!isMnemonicValid(opts.mnemonic)) {
58
+ throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'mnemonic contains invalid BIP39 words or failed checksum', { wordCount: words.length });
59
+ }
60
+ if (typeof opts.nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(opts.nodeAddress)) {
61
+ throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (47 characters)', { value: opts.nodeAddress });
62
+ }
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 });
65
+ }
66
+
67
+ // ─── Shared Connect Flow (eliminates connectDirect/connectViaPlan duplication) ─
68
+
69
+ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _defaultState) {
70
+ const signal = opts.signal; // AbortController support
71
+ const _connectStart = Date.now(); // v25: metrics timing
72
+ checkAborted(signal);
73
+
74
+ // Handle existing connection
75
+ if (state.isConnected) {
76
+ if (opts.allowReconnect === false) {
77
+ throw new SentinelError(ErrorCodes.ALREADY_CONNECTED,
78
+ 'Already connected. Disconnect first or set allowReconnect: true.',
79
+ { nodeAddress: state.connection?.nodeAddress });
80
+ }
81
+ const prev = state.connection;
82
+ await disconnectState(state);
83
+ if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Disconnected from ${prev?.nodeAddress || 'previous node'}`);
84
+ }
85
+
86
+ const onProgress = opts.onProgress || null;
87
+ const logFn = opts.log || defaultLog;
88
+ const fullTunnel = opts.fullTunnel !== false; // v26c: default TRUE (was false — caused "IP didn't change" confusion)
89
+ const systemProxy = opts.systemProxy === true;
90
+ const killSwitch = opts.killSwitch === true;
91
+ const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };
92
+ const tlsTrust = opts.tlsTrust || 'tofu'; // 'tofu' (default) | 'none' (insecure)
93
+
94
+ events.emit('connecting', { nodeAddress: opts.nodeAddress });
95
+
96
+ // 1. Wallet + key derivation in parallel (both derive from same mnemonic, independent)
97
+ // v21: parallelized — saves ~300ms (was sequential)
98
+ progress(onProgress, logFn, 'wallet', 'Setting up wallet...');
99
+ checkAborted(signal);
100
+ const [{ wallet, account }, privKey] = await Promise.all([
101
+ cachedCreateWallet(opts.mnemonic),
102
+ privKeyFromMnemonic(opts.mnemonic),
103
+ ]);
104
+
105
+ // Store mnemonic on state for session-end TX on disconnect (fire-and-forget cleanup)
106
+ state._mnemonic = opts.mnemonic;
107
+
108
+ // 2. RPC connect + LCD lookup in parallel (independent network calls)
109
+ // v21: parallelized — saves 1-3s (was sequential)
110
+ progress(onProgress, logFn, 'wallet', 'Connecting to chain endpoints...');
111
+ checkAborted(signal);
112
+
113
+ const rpcPromise = opts.rpcUrl
114
+ ? createClient(opts.rpcUrl, wallet).then(client => ({ client, rpc: opts.rpcUrl, name: 'user-provided' }))
115
+ : tryWithFallback(RPC_ENDPOINTS, async (url) => createClient(url, wallet), 'RPC connect')
116
+ .then(({ result, endpoint, endpointName }) => ({ client: result, rpc: endpoint, name: endpointName }));
117
+
118
+ const lcdPromise = opts.lcdUrl
119
+ ? queryNode(opts.nodeAddress, { lcdUrl: opts.lcdUrl }).then(info => ({ nodeInfo: info, lcd: opts.lcdUrl }))
120
+ : queryNode(opts.nodeAddress).then(info => ({ nodeInfo: info, lcd: DEFAULT_LCD }));
121
+
122
+ const [rpcResult, lcdResult] = await Promise.all([rpcPromise, lcdPromise]);
123
+ const { client, rpc } = rpcResult;
124
+ if (rpcResult.name !== 'user-provided') progress(onProgress, logFn, 'wallet', `RPC: ${rpcResult.name} (${rpc})`);
125
+ let { nodeInfo, lcd } = lcdResult;
126
+
127
+ // Balance check — verify wallet has enough P2P before paying for session
128
+ // Dry-run mode skips balance enforcement (wallet may be unfunded)
129
+ checkAborted(signal);
130
+ try {
131
+ const bal = await getBalance(client, account.address);
132
+ progress(onProgress, logFn, 'wallet', `${account.address} | ${bal.dvpn.toFixed(1)} P2P`);
133
+ if (!opts.dryRun && bal.udvpn < 100000) {
134
+ throw new ChainError(ErrorCodes.INSUFFICIENT_BALANCE,
135
+ `Wallet has ${bal.dvpn.toFixed(2)} P2P — need at least 0.1 P2P for a session. Fund address ${account.address} with P2P tokens.`,
136
+ { balance: bal, address: account.address }
137
+ );
138
+ }
139
+ } catch (balErr) {
140
+ if (balErr.code === ErrorCodes.INSUFFICIENT_BALANCE) throw balErr;
141
+ // Non-fatal: balance check failed (network issue) — continue and let chain reject if needed
142
+ progress(onProgress, logFn, 'wallet', `${account.address} | balance check skipped (${balErr.message})`);
143
+ }
144
+
145
+ // 3. Check node status
146
+ progress(onProgress, logFn, 'node-check', `Checking node ${opts.nodeAddress}...`);
147
+ const nodeAgent = createNodeHttpsAgent(opts.nodeAddress, tlsTrust);
148
+ const status = await nodeStatusV3(nodeInfo.remote_url, nodeAgent);
149
+ progress(onProgress, logFn, 'node-check', `${status.moniker} (${status.type}) - ${status.location.city}, ${status.location.country}`);
150
+
151
+ // Pre-verify: node's address must match what we're paying for.
152
+ // Prevents wasting tokens when remote URL serves a different node.
153
+ if (status.address && status.address !== opts.nodeAddress) {
154
+ throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node address mismatch: remote URL serves ${status.address}, not ${opts.nodeAddress}. Aborting before payment.`, { expected: opts.nodeAddress, actual: status.address });
155
+ }
156
+
157
+ const extremeDrift = status.type === 'v2ray' && status.clockDriftSec !== null && Math.abs(status.clockDriftSec) > 120;
158
+ if (extremeDrift) {
159
+ logFn?.(`Warning: clock drift ${status.clockDriftSec}s — VMess will fail but VLess may work`);
160
+ }
161
+
162
+ // 2b. PRE-VALIDATE tunnel requirements BEFORE paying
163
+ progress(onProgress, logFn, 'validate', 'Checking tunnel requirements...');
164
+ const resolvedV2rayPath = validateTunnelRequirements(status.type, opts.v2rayExePath);
165
+
166
+ // 2c. PRE-PAYMENT PORT PROBE — verify V2Ray node has open transport ports
167
+ // before spending P2P tokens. Prevents paying for sessions on nodes whose
168
+ // V2Ray service crashed (status API responds but V2Ray ports are dead).
169
+ // WireGuard skips this — WG uses a single UDP port that can't be TCP-probed.
170
+ if (status.type === 'v2ray') {
171
+ const serverHost = new URL(nodeInfo.remote_url).hostname;
172
+ const probePorts = [8686, 8787, 7874, 7876, 443, 8443];
173
+ let anyOpen = false;
174
+ for (const port of probePorts) {
175
+ if (await waitForPort(port, 2000, serverHost)) {
176
+ anyOpen = true;
177
+ progress(onProgress, logFn, 'validate', `V2Ray port ${port} open on ${serverHost}`);
178
+ break;
179
+ }
180
+ }
181
+ if (!anyOpen) {
182
+ throw new NodeError(ErrorCodes.NODE_OFFLINE,
183
+ `V2Ray node ${opts.nodeAddress} has no open transport ports (probed ${probePorts.join(',')} on ${serverHost}). Node status API responds but V2Ray service is dead. Skipping to save tokens.`,
184
+ { nodeAddress: opts.nodeAddress, serverHost, probedPorts: probePorts });
185
+ }
186
+ }
187
+
188
+ // ── DRY-RUN: return mock result without paying, handshaking, or tunneling ──
189
+ if (opts.dryRun) {
190
+ privKey.fill(0);
191
+ progress(onProgress, logFn, 'dry-run', 'Dry-run complete — no TX broadcast, no tunnel created');
192
+ events.emit('connected', { sessionId: BigInt(0), serviceType: status.type, nodeAddress: opts.nodeAddress, dryRun: true });
193
+ return {
194
+ dryRun: true,
195
+ sessionId: BigInt(0),
196
+ serviceType: status.type,
197
+ nodeAddress: opts.nodeAddress,
198
+ nodeMoniker: status.moniker,
199
+ nodeLocation: status.location,
200
+ walletAddress: account.address,
201
+ rpcUsed: rpc,
202
+ lcdUsed: lcd,
203
+ cleanup: async () => {},
204
+ };
205
+ }
206
+
207
+ // 3. Payment (strategy-specific)
208
+ checkAborted(signal);
209
+ const payCtx = { client, account, nodeInfo, lcd, logFn, onProgress, signal, timeouts };
210
+ const { sessionId: paidSessionId, subscriptionId } = await paymentStrategy(payCtx);
211
+ let sessionId = paidSessionId;
212
+
213
+ // 4. Handshake & tunnel
214
+ // Wait 5s after session TX for node to index the session on-chain.
215
+ // Without this, the node may return 409 "already exists" because it's still
216
+ // processing the previous block's state changes.
217
+ progress(onProgress, logFn, 'handshake', 'Waiting for node to index session...');
218
+ await sleep(5000);
219
+ progress(onProgress, logFn, 'handshake', 'Starting handshake...');
220
+ checkAborted(signal);
221
+ const tunnelOpts = {
222
+ serviceType: status.type,
223
+ remoteUrl: nodeInfo.remote_url,
224
+ serverHost: new URL(nodeInfo.remote_url).hostname,
225
+ sessionId,
226
+ privKey,
227
+ v2rayExePath: resolvedV2rayPath,
228
+ fullTunnel,
229
+ splitIPs: opts.splitIPs,
230
+ systemProxy,
231
+ killSwitch,
232
+ dns: opts.dns,
233
+ onProgress,
234
+ logFn,
235
+ extremeDrift,
236
+ clockDriftSec: status.clockDriftSec,
237
+ nodeAddress: opts.nodeAddress,
238
+ timeouts,
239
+ signal,
240
+ nodeAgent,
241
+ state,
242
+ };
243
+
244
+ // ─── Handshake with "already exists" (409) retry ───
245
+ // After session TX confirms, the node may still be indexing. Handshake can
246
+ // return 409 "already exists" if the node hasn't finished processing.
247
+ // Retry schedule: wait 15s, then 20s. If still fails, fall back to
248
+ // retryStrategy (pay for fresh session) or throw.
249
+ const _isAlreadyExists = (err) => {
250
+ const msg = String(err?.message || '');
251
+ const st = err?.details?.status;
252
+ return msg.includes('already exists') || st === 409;
253
+ };
254
+
255
+ let handshakeResult = null;
256
+ let handshakeErr = null;
257
+ const alreadyExistsDelays = [15000, 20000]; // retry delays for 409 "already exists"
258
+ let alreadyExistsAttempt = 0;
259
+
260
+ for (;;) {
261
+ try {
262
+ handshakeResult = await performHandshake(tunnelOpts);
263
+ break; // success
264
+ } catch (err) {
265
+ if (_isAlreadyExists(err) && alreadyExistsAttempt < alreadyExistsDelays.length) {
266
+ const delayMs = alreadyExistsDelays[alreadyExistsAttempt];
267
+ progress(onProgress, logFn, 'handshake', `Session indexing race (409) — retrying in ${delayMs / 1000}s (attempt ${alreadyExistsAttempt + 1}/${alreadyExistsDelays.length})...`);
268
+ await sleep(delayMs);
269
+ checkAborted(signal);
270
+ alreadyExistsAttempt++;
271
+ continue;
272
+ }
273
+ handshakeErr = err;
274
+ break;
275
+ }
276
+ }
277
+
278
+ try {
279
+ if (handshakeResult) {
280
+ markSessionActive(String(sessionId), opts.nodeAddress);
281
+ if (subscriptionId) handshakeResult.subscriptionId = subscriptionId;
282
+ _recordMetric(opts.nodeAddress, true, Date.now() - _connectStart); // v25: metrics
283
+ events.emit('connected', { sessionId, serviceType: status.type, nodeAddress: opts.nodeAddress });
284
+ return handshakeResult;
285
+ }
286
+
287
+ // Handshake failed
288
+ const hsErr = handshakeErr;
289
+ _recordMetric(opts.nodeAddress, false, Date.now() - _connectStart); // v25: metrics
290
+ markSessionPoisoned(String(sessionId), opts.nodeAddress, hsErr.message);
291
+
292
+ // v25: Attach partial connection state for recovery (#2)
293
+ if (!hsErr.details) hsErr.details = {};
294
+ hsErr.details.sessionId = String(sessionId);
295
+ hsErr.details.nodeAddress = opts.nodeAddress;
296
+ hsErr.details.failedAt = 'handshake';
297
+ hsErr.details.serviceType = status.type;
298
+
299
+ // "already exists" final fallback: pay for fresh session and retry handshake
300
+ if (retryStrategy && _isAlreadyExists(hsErr)) {
301
+ progress(onProgress, logFn, 'session', `Session ${sessionId} stale on node — paying for fresh session...`);
302
+ checkAborted(signal);
303
+ const retry = await retryStrategy(payCtx, hsErr);
304
+ sessionId = retry.sessionId;
305
+ tunnelOpts.sessionId = sessionId;
306
+ try {
307
+ const retryResult = await performHandshake(tunnelOpts);
308
+ markSessionActive(String(sessionId), opts.nodeAddress);
309
+ events.emit('connected', { sessionId, serviceType: status.type, nodeAddress: opts.nodeAddress });
310
+ return retryResult;
311
+ } catch (retryErr) {
312
+ // Clean up any partially-installed tunnel before re-throwing
313
+ if (state.wgTunnel) {
314
+ try { await disconnectWireGuard(); } catch {} // cleanup: best-effort
315
+ state.wgTunnel = null;
316
+ }
317
+ if (state.v2rayProc) {
318
+ try { killV2RayProc(state.v2rayProc); } catch {} // cleanup: best-effort
319
+ state.v2rayProc = null;
320
+ }
321
+ markSessionPoisoned(String(sessionId), opts.nodeAddress, retryErr.message);
322
+ if (!retryErr.details) retryErr.details = {};
323
+ retryErr.details.sessionId = String(sessionId);
324
+ retryErr.details.nodeAddress = opts.nodeAddress;
325
+ retryErr.details.failedAt = 'handshake_retry';
326
+ events.emit('error', retryErr);
327
+ throw retryErr;
328
+ }
329
+ }
330
+ events.emit('error', hsErr);
331
+ throw hsErr;
332
+ } finally {
333
+ // Zero mnemonic-derived private key — guaranteed even if exceptions thrown
334
+ privKey.fill(0);
335
+ }
336
+ }
337
+
338
+ // ─── Direct Connection (Pay per GB) ─────────────────────────────────────────
339
+
340
+ /**
341
+ * Connect to a node by paying directly per GB.
342
+ *
343
+ * Flow: check existing session → pay for new session → handshake → tunnel
344
+ *
345
+ * @param {object} opts
346
+ * @param {string} opts.mnemonic - BIP39 mnemonic
347
+ * @param {string} opts.nodeAddress - sentnode1... address
348
+ * @param {string} opts.rpcUrl - Chain RPC (default: https://rpc.sentinel.co:443)
349
+ * @param {string} opts.lcdUrl - Chain LCD (default: https://lcd.sentinel.co)
350
+ * @param {number} opts.gigabytes - Bandwidth to purchase (default: 1)
351
+ * @param {boolean} opts.preferHourly - Prefer hourly sessions when cheaper than per-GB (default: false).
352
+ * @param {string} opts.v2rayExePath - Path to v2ray.exe (auto-detected if missing)
353
+ * @param {boolean} opts.fullTunnel - WireGuard: route ALL traffic through VPN (default: true).
354
+ * @param {string[]} opts.splitIPs - WireGuard split tunnel IPs. Overrides fullTunnel.
355
+ * @param {boolean} opts.systemProxy - V2Ray: auto-set Windows system SOCKS proxy (default: false).
356
+ * @param {boolean} opts.killSwitch - Enable kill switch (default: false). Windows only.
357
+ * @param {boolean} opts.forceNewSession - Always pay for a new session (default: false).
358
+ * @param {function} opts.onProgress - Optional callback: (step, detail) => void
359
+ * @param {function} opts.log - Optional log function (default: console.log).
360
+ * @returns {{ sessionId, serviceType, socksPort?, cleanup() }}
361
+ */
362
+ export async function connectDirect(opts) {
363
+ warnIfNoCleanup('connectDirect');
364
+ // ── Input validation (fail fast before any network/chain calls) ──
365
+ validateConnectOpts(opts, 'connectDirect');
366
+ if (opts.gigabytes != null) {
367
+ const g = Number(opts.gigabytes);
368
+ if (!Number.isInteger(g) || g < 1 || g > 100) throw new ValidationError(ErrorCodes.INVALID_GIGABYTES, 'gigabytes must be a positive integer (1-100)', { value: opts.gigabytes });
369
+ }
370
+
371
+ // ── Connection mutex (prevent concurrent connects) ──
372
+ const ownsLock = !opts._skipLock && !getConnectLock();
373
+ if (!opts._skipLock && getConnectLock()) throw new SentinelError(ErrorCodes.ALREADY_CONNECTED, 'Connection already in progress');
374
+ if (ownsLock) setConnectLock(true);
375
+ try {
376
+
377
+ const gigabytes = opts.gigabytes || 1;
378
+ const forceNewSession = !!opts.forceNewSession;
379
+
380
+ // ── Fast Reconnect: check for saved credentials ──
381
+ 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);
385
+ if (fast) {
386
+ clearCircuitBreaker(opts.nodeAddress);
387
+ return fast;
388
+ }
389
+ }
390
+
391
+ // Payment strategy for direct pay-per-GB
392
+ async function directPayment(ctx) {
393
+ const { client, account, nodeInfo, lcd, logFn, onProgress, signal } = ctx;
394
+
395
+ // Check for existing session (avoid double-pay) — skip if forceNewSession
396
+ let sessionId = null;
397
+ if (!forceNewSession) {
398
+ progress(onProgress, logFn, 'session', 'Checking for existing session...');
399
+ checkAborted(signal);
400
+ sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress);
401
+ if (sessionId && isSessionPoisoned(String(sessionId))) {
402
+ progress(onProgress, logFn, 'session', `Session ${sessionId} previously failed — skipping`);
403
+ sessionId = null;
404
+ }
405
+ }
406
+
407
+ if (sessionId) {
408
+ progress(onProgress, logFn, 'session', `Reusing existing session: ${sessionId}`);
409
+ return { sessionId: BigInt(sessionId) };
410
+ }
411
+
412
+ // Pay for new session — choose hourly vs per-GB pricing
413
+ const udvpnPrice = nodeInfo.gigabyte_prices.find(p => p.denom === 'udvpn');
414
+ if (!udvpnPrice) throw new NodeError(ErrorCodes.NODE_NO_UDVPN, 'Node does not accept udvpn', { nodeAddress: opts.nodeAddress });
415
+
416
+ // v34: Pre-payment price validation. Some nodes have prices that pass registration
417
+ // but fail MsgStartSession (chain code 106 "invalid price"). Known bad pattern:
418
+ // base_value containing "0.005" with quote_value "25000000". Skip these to save gas.
419
+ const bv = udvpnPrice.base_value || '';
420
+ if (bv.startsWith('0.005') || bv === '5000000000000000') {
421
+ throw new NodeError(ErrorCodes.NODE_OFFLINE,
422
+ `Node ${opts.nodeAddress} has a price (${bv}) known to be rejected by chain MsgStartSession (code 106 "invalid price"). Skipping to save gas.`,
423
+ { nodeAddress: opts.nodeAddress, baseValue: bv, quoteValue: udvpnPrice.quote_value });
424
+ }
425
+
426
+ // Determine pricing model: explicit hours > preferHourly > default GB
427
+ const hourlyPrice = (nodeInfo.hourly_prices || []).find(p => p.denom === 'udvpn');
428
+ const explicitHours = opts.hours > 0 ? opts.hours : 0;
429
+ const useHourly = explicitHours > 0 || (opts.preferHourly && !!hourlyPrice);
430
+
431
+ if (useHourly && !hourlyPrice) {
432
+ throw new NodeError(ErrorCodes.NODE_OFFLINE, `Node ${opts.nodeAddress} has no hourly pricing — cannot use hours-based session. Use gigabytes instead.`);
433
+ }
434
+
435
+ const sessionGigabytes = useHourly ? 0 : gigabytes;
436
+ const sessionHours = useHourly ? (explicitHours || 1) : 0;
437
+ const sessionMaxPrice = useHourly ? hourlyPrice : udvpnPrice;
438
+
439
+ const msg = {
440
+ typeUrl: MSG_TYPES.START_SESSION,
441
+ value: {
442
+ from: account.address,
443
+ node_address: opts.nodeAddress,
444
+ gigabytes: sessionGigabytes,
445
+ hours: sessionHours,
446
+ max_price: { denom: 'udvpn', base_value: sessionMaxPrice.base_value, quote_value: sessionMaxPrice.quote_value },
447
+ },
448
+ };
449
+
450
+ checkAborted(signal);
451
+ const pricingMode = useHourly ? 'hourly' : 'per-GB';
452
+ progress(onProgress, logFn, 'session', `Broadcasting session TX (${pricingMode})...`);
453
+ const result = await broadcastWithInactiveRetry(client, account.address, [msg], logFn, onProgress);
454
+ const extractedId = extractId(result, /session/i, ['session_id', 'id']);
455
+ if (!extractedId) throw new ChainError(ErrorCodes.SESSION_EXTRACT_FAILED, 'Failed to extract session ID from TX result — check TX events', { txHash: result.transactionHash });
456
+ sessionId = BigInt(extractedId);
457
+ progress(onProgress, logFn, 'session', `Session created: ${sessionId} (${pricingMode}, tx: ${result.transactionHash})`);
458
+ return { sessionId };
459
+ }
460
+
461
+ // Retry strategy: if handshake fails with "already exists", pay for fresh session
462
+ async function retryPayment(ctx, _hsErr) {
463
+ const { client, account, nodeInfo, logFn, onProgress, signal } = ctx;
464
+ const udvpnPrice = nodeInfo.gigabyte_prices.find(p => p.denom === 'udvpn');
465
+ if (!udvpnPrice) throw new NodeError(ErrorCodes.NODE_NO_UDVPN, 'Node does not accept udvpn', { nodeAddress: opts.nodeAddress });
466
+
467
+ // Retry uses same hourly logic as directPayment
468
+ const hourlyPrice = (nodeInfo.hourly_prices || []).find(p => p.denom === 'udvpn');
469
+ const explicitHours = opts.hours > 0 ? opts.hours : 0;
470
+ const useHourly = explicitHours > 0 || (opts.preferHourly && !!hourlyPrice);
471
+
472
+ const retryGigabytes = useHourly ? 0 : gigabytes;
473
+ const retryHours = useHourly ? (explicitHours || 1) : 0;
474
+ const retryMaxPrice = useHourly ? hourlyPrice : udvpnPrice;
475
+
476
+ const msg = {
477
+ typeUrl: MSG_TYPES.START_SESSION,
478
+ value: {
479
+ from: account.address,
480
+ node_address: opts.nodeAddress,
481
+ gigabytes: retryGigabytes,
482
+ hours: retryHours,
483
+ max_price: { denom: 'udvpn', base_value: retryMaxPrice.base_value, quote_value: retryMaxPrice.quote_value },
484
+ },
485
+ };
486
+ checkAborted(signal);
487
+ const result = await broadcastWithInactiveRetry(client, account.address, [msg], logFn, onProgress);
488
+ const retryExtracted = extractId(result, /session/i, ['session_id', 'id']);
489
+ if (!retryExtracted) throw new ChainError(ErrorCodes.SESSION_EXTRACT_FAILED, 'Failed to extract session ID from retry TX result — check TX events', { txHash: result.transactionHash });
490
+ const sessionId = BigInt(retryExtracted);
491
+ progress(onProgress, logFn, 'session', `Fresh session: ${sessionId} (tx: ${result.transactionHash})`);
492
+ return { sessionId };
493
+ }
494
+
495
+ const result = await connectInternal(opts, directPayment, retryPayment, opts._state || _defaultState);
496
+ // Record success — clear circuit breaker for this node
497
+ clearCircuitBreaker(opts.nodeAddress);
498
+ return result;
499
+
500
+ } finally { if (ownsLock) setConnectLock(false); }
501
+ }
502
+
503
+ // ─── Auto-Connect with Fallback ─────────────────────────────────────────────
504
+
505
+ /**
506
+ * Connect with auto-fallback: on failure, try next best node automatically.
507
+ * Uses queryOnlineNodes to find candidates, then tries up to `maxAttempts` nodes.
508
+ *
509
+ * @param {object} opts - Same as connectDirect, plus:
510
+ * @param {number} opts.maxAttempts - Max nodes to try (default: 3)
511
+ * @param {string} opts.serviceType - Filter nodes by type: 'wireguard' | 'v2ray' (optional)
512
+ * @param {string[]} opts.countries - Only try nodes in these countries (optional)
513
+ * @param {string[]} opts.excludeCountries - Skip nodes in these countries (optional)
514
+ * @param {number} opts.maxPriceDvpn - Max price in P2P per GB (optional)
515
+ * @param {number} opts.minScore - Minimum quality score (optional)
516
+ * @param {{ threshold?: number, ttlMs?: number }} opts.circuitBreaker - Per-call circuit breaker config (optional)
517
+ * @returns {{ sessionId, serviceType, socksPort?, cleanup(), nodeAddress }}
518
+ */
519
+ export async function connectAuto(opts) {
520
+ warnIfNoCleanup('connectAuto');
521
+ if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'connectAuto() requires an options object');
522
+ if (typeof opts.mnemonic !== 'string' || opts.mnemonic.trim().split(/\s+/).length < 12) {
523
+ throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'mnemonic must be a 12+ word BIP39 string');
524
+ }
525
+ if (opts.maxAttempts != null && (!Number.isInteger(opts.maxAttempts) || opts.maxAttempts < 1)) {
526
+ throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'maxAttempts must be a positive integer');
527
+ }
528
+
529
+ // ── Connection mutex (prevent concurrent connects) ──
530
+ if (getConnectLock()) throw new SentinelError(ErrorCodes.ALREADY_CONNECTED, 'Connection already in progress');
531
+ setConnectLock(true);
532
+ setAbortConnect(false); // v30: reset abort flag at start of new connection attempt
533
+ try {
534
+
535
+ // v25: per-call circuit breaker config
536
+ if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker);
537
+
538
+ const maxAttempts = opts.maxAttempts || 3;
539
+ const logFn = opts.log || console.log;
540
+ const errors = [];
541
+
542
+ // If nodeAddress specified, try it first (skip circuit breaker check for explicit choice)
543
+ if (opts.nodeAddress) {
544
+ // v30: Check abort flag before each attempt
545
+ if (getAbortConnect()) {
546
+ setAbortConnect(false);
547
+ throw new SentinelError(ErrorCodes.ABORTED, 'Connection was cancelled by disconnect');
548
+ }
549
+ try {
550
+ return await connectDirect({ ...opts, _skipLock: true });
551
+ } catch (err) {
552
+ recordNodeFailure(opts.nodeAddress);
553
+ errors.push({ address: opts.nodeAddress, error: err.message });
554
+ logFn(`[connectAuto] ${opts.nodeAddress} failed: ${err.message} — trying fallback nodes...`);
555
+ }
556
+ }
557
+
558
+ // Find online nodes, excluding circuit-broken ones
559
+ logFn('[connectAuto] Scanning for online nodes...');
560
+ const nodes = await queryOnlineNodes({
561
+ serviceType: opts.serviceType,
562
+ maxNodes: maxAttempts * 3,
563
+ onNodeProbed: opts.onNodeProbed,
564
+ });
565
+
566
+ // v30: Check abort after slow queryOnlineNodes call
567
+ if (getAbortConnect()) {
568
+ setAbortConnect(false);
569
+ throw new SentinelError(ErrorCodes.ABORTED, 'Connection was cancelled by disconnect');
570
+ }
571
+
572
+ // v25: Apply filters using filterNodes + custom exclusions
573
+ let filtered = nodes.filter(n => n.address !== opts.nodeAddress && !isCircuitOpen(n.address));
574
+ if (opts.countries || opts.maxPriceDvpn != null || opts.minScore != null) {
575
+ filtered = filterNodes(filtered, {
576
+ country: opts.countries?.[0], // filterNodes supports single country
577
+ maxPriceDvpn: opts.maxPriceDvpn,
578
+ minScore: opts.minScore,
579
+ });
580
+ // Multi-country support (filterNodes does single, we handle array)
581
+ if (opts.countries && opts.countries.length > 1) {
582
+ const lc = opts.countries.map(c => c.toLowerCase());
583
+ filtered = filtered.filter(n => lc.some(c => (n.country || '').toLowerCase().includes(c)));
584
+ }
585
+ }
586
+ if (opts.excludeCountries?.length) {
587
+ const exc = opts.excludeCountries.map(c => c.toLowerCase());
588
+ filtered = filtered.filter(n => !exc.some(c => (n.country || '').toLowerCase().includes(c)));
589
+ }
590
+
591
+ // v25: Emit events for skipped nodes (clock drift, circuit breaker)
592
+ const skipped = nodes.filter(n => !filtered.includes(n) && n.address !== opts.nodeAddress);
593
+ for (const n of skipped) {
594
+ if (isCircuitOpen(n.address)) {
595
+ events.emit('progress', { event: 'node.skipped', reason: 'circuit_breaker', nodeAddress: n.address, ts: Date.now() });
596
+ }
597
+ if (n.clockDriftSec !== null && Math.abs(n.clockDriftSec) > 120 && n.serviceType === 'v2ray') {
598
+ events.emit('progress', { event: 'node.skipped', reason: 'clock_drift', nodeAddress: n.address, driftSeconds: n.clockDriftSec, ts: Date.now() });
599
+ }
600
+ }
601
+
602
+ // v28: nodePool — restrict to a specific set of node addresses
603
+ if (opts.nodePool?.length) {
604
+ const poolSet = new Set(opts.nodePool);
605
+ filtered = filtered.filter(n => poolSet.has(n.address));
606
+ }
607
+
608
+ const candidates = filtered;
609
+ for (let i = 0; i < Math.min(candidates.length, maxAttempts); i++) {
610
+ // v30: Check abort flag before each retry — disconnect() sets this
611
+ if (getAbortConnect()) {
612
+ setAbortConnect(false);
613
+ throw new SentinelError(ErrorCodes.ABORTED, 'Connection was cancelled by disconnect');
614
+ }
615
+ const node = candidates[i];
616
+ logFn(`[connectAuto] Trying ${node.address} (${i + 1}/${Math.min(candidates.length, maxAttempts)})...`);
617
+ try {
618
+ return await connectDirect({ ...opts, nodeAddress: node.address, _skipLock: true });
619
+ } catch (err) {
620
+ recordNodeFailure(node.address);
621
+ errors.push({ address: node.address, error: err.message });
622
+ logFn(`[connectAuto] ${node.address} failed: ${err.message}`);
623
+ }
624
+ }
625
+
626
+ throw new SentinelError(ErrorCodes.ALL_NODES_FAILED,
627
+ `All ${errors.length} nodes failed`,
628
+ { attempts: errors });
629
+
630
+ } finally { setConnectLock(false); }
631
+ }
632
+
633
+ // ─── Plan Connection (Subscribe to existing plan) ────────────────────────────
634
+
635
+ /**
636
+ * Connect via a plan subscription.
637
+ *
638
+ * Flow: subscribe to plan → start session via subscription → handshake → tunnel
639
+ *
640
+ * @param {object} opts
641
+ * @param {string} opts.mnemonic - BIP39 mnemonic
642
+ * @param {number|string} opts.planId - Plan ID to subscribe to
643
+ * @param {string} opts.nodeAddress - sentnode1... address (must be linked to plan)
644
+ * @param {string} opts.rpcUrl - Chain RPC
645
+ * @param {string} opts.lcdUrl - Chain LCD
646
+ * @param {string} opts.v2rayExePath - Path to v2ray.exe (auto-detected if missing)
647
+ * @param {boolean} opts.fullTunnel - WireGuard: route ALL traffic (default: true)
648
+ * @param {string[]} opts.splitIPs - WireGuard split tunnel IPs (overrides fullTunnel)
649
+ * @param {boolean} opts.systemProxy - V2Ray: auto-set Windows system proxy (default: false)
650
+ * @param {boolean} opts.killSwitch - Enable kill switch (default: false)
651
+ * @param {function} opts.onProgress - Optional callback: (step, detail) => void
652
+ * @param {function} opts.log - Optional log function (default: console.log)
653
+ */
654
+ export async function connectViaPlan(opts) {
655
+ warnIfNoCleanup('connectViaPlan');
656
+ // ── Input validation ──
657
+ validateConnectOpts(opts, 'connectViaPlan');
658
+ if (opts.planId == null || opts.planId === '' || opts.planId === 0 || opts.planId === '0') {
659
+ throw new ValidationError(ErrorCodes.INVALID_PLAN_ID, 'connectViaPlan requires opts.planId (number or string)', { value: opts.planId });
660
+ }
661
+ let planIdBigInt;
662
+ try {
663
+ planIdBigInt = BigInt(opts.planId);
664
+ } catch {
665
+ throw new ValidationError(ErrorCodes.INVALID_PLAN_ID, `Invalid planId: "${opts.planId}" — must be a numeric value`, { value: opts.planId });
666
+ }
667
+
668
+ // ── Connection mutex (prevent concurrent connects) ──
669
+ if (getConnectLock()) throw new SentinelError(ErrorCodes.ALREADY_CONNECTED, 'Connection already in progress');
670
+ setConnectLock(true);
671
+ try {
672
+
673
+ // Payment strategy for plan subscription
674
+ async function planPayment(ctx) {
675
+ const { client, account, lcd: lcdUrl, logFn, onProgress, signal } = ctx;
676
+ const msg = {
677
+ typeUrl: MSG_TYPES.PLAN_START_SESSION,
678
+ value: {
679
+ from: account.address,
680
+ id: planIdBigInt,
681
+ denom: 'udvpn',
682
+ renewalPricePolicy: 0,
683
+ nodeAddress: opts.nodeAddress,
684
+ },
685
+ };
686
+
687
+ checkAborted(signal);
688
+
689
+ // Fee grant: the app passes the plan owner's address as feeGranter.
690
+ const feeGranter = opts.feeGranter || null;
691
+
692
+ progress(null, opts.log || defaultLog, 'session', `Subscribing to plan ${opts.planId} + starting session${feeGranter ? ' (fee granted)' : ''}...`);
693
+
694
+ let result;
695
+ if (feeGranter) {
696
+ try {
697
+ result = await broadcastWithFeeGrant(client, account.address, [msg], feeGranter);
698
+ } 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...');
701
+ result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
702
+ }
703
+ } else {
704
+ result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
705
+ }
706
+ const planExtracted = extractId(result, /session/i, ['session_id', 'id']);
707
+ if (!planExtracted) throw new ChainError(ErrorCodes.SESSION_EXTRACT_FAILED, 'Failed to extract session ID from plan TX result — check TX events', { txHash: result.transactionHash });
708
+ const sessionId = BigInt(planExtracted);
709
+ const subscriptionId = extractId(result, /subscription/i, ['subscription_id', 'id']);
710
+ progress(null, opts.log || defaultLog, 'session', `Session: ${sessionId}${subscriptionId ? `, Subscription: ${subscriptionId}` : ''}`);
711
+ return { sessionId, subscriptionId };
712
+ }
713
+
714
+ // No retry for plan connections (plan payment is idempotent)
715
+ const result = await connectInternal(opts, planPayment, null, opts._state || _defaultState);
716
+ return result;
717
+
718
+ } finally { setConnectLock(false); }
719
+ }
720
+
721
+ // ─── Subscription Connection (Use existing subscription) ─────────────────
722
+
723
+ /**
724
+ * Connect via an existing subscription.
725
+ *
726
+ * Flow: start session via subscription → handshake → tunnel
727
+ *
728
+ * @param {object} opts
729
+ * @param {string} opts.mnemonic - BIP39 mnemonic
730
+ * @param {number|string} opts.subscriptionId - Existing subscription ID
731
+ * @param {string} opts.nodeAddress - sentnode1... address
732
+ * @param {string} opts.rpcUrl - Chain RPC
733
+ * @param {string} opts.lcdUrl - Chain LCD
734
+ * @param {string} opts.v2rayExePath - Path to v2ray.exe (auto-detected if missing)
735
+ * @param {boolean} opts.fullTunnel - WireGuard: route ALL traffic (default: true)
736
+ * @param {string[]} opts.splitIPs - WireGuard split tunnel IPs (overrides fullTunnel)
737
+ * @param {boolean} opts.systemProxy - V2Ray: auto-set Windows system proxy (default: false)
738
+ * @param {boolean} opts.killSwitch - Enable kill switch (default: false)
739
+ * @param {function} opts.onProgress - Optional callback: (step, detail) => void
740
+ * @param {function} opts.log - Optional log function (default: console.log)
741
+ */
742
+ export async function connectViaSubscription(opts) {
743
+ warnIfNoCleanup('connectViaSubscription');
744
+ validateConnectOpts(opts, 'connectViaSubscription');
745
+ if (opts.subscriptionId == null || opts.subscriptionId === '') {
746
+ throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'connectViaSubscription requires opts.subscriptionId (number or string)', { value: opts.subscriptionId });
747
+ }
748
+ let subIdBigInt;
749
+ try {
750
+ subIdBigInt = BigInt(opts.subscriptionId);
751
+ } catch {
752
+ throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `Invalid subscriptionId: "${opts.subscriptionId}" — must be a numeric value`, { value: opts.subscriptionId });
753
+ }
754
+
755
+ // ── Connection mutex (prevent concurrent connects) ──
756
+ if (getConnectLock()) throw new SentinelError(ErrorCodes.ALREADY_CONNECTED, 'Connection already in progress');
757
+ setConnectLock(true);
758
+ try {
759
+
760
+ async function subPayment(ctx) {
761
+ const { client, account, logFn, onProgress, signal } = ctx;
762
+ const msg = {
763
+ typeUrl: MSG_TYPES.SUB_START_SESSION,
764
+ value: {
765
+ from: account.address,
766
+ id: subIdBigInt,
767
+ nodeAddress: opts.nodeAddress,
768
+ },
769
+ };
770
+
771
+ checkAborted(signal);
772
+ progress(null, opts.log || defaultLog, 'session', `Starting session via subscription ${opts.subscriptionId}...`);
773
+ const result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
774
+ const extracted = extractId(result, /session/i, ['session_id', 'id']);
775
+ if (!extracted) throw new ChainError(ErrorCodes.SESSION_EXTRACT_FAILED, 'Failed to extract session ID from subscription TX result', { txHash: result.transactionHash });
776
+ const sessionId = BigInt(extracted);
777
+ progress(null, opts.log || defaultLog, 'session', `Session: ${sessionId} (subscription ${opts.subscriptionId})`);
778
+ return { sessionId, subscriptionId: opts.subscriptionId };
779
+ }
780
+
781
+ const result = await connectInternal(opts, subPayment, null, opts._state || _defaultState);
782
+ return result;
783
+
784
+ } finally { setConnectLock(false); }
785
+ }
786
+
787
+ // ─── Quick Connect (v26c) ────────────────────────────────────────────────────
788
+
789
+ /**
790
+ * One-call VPN connection. Handles everything: dependency check, cleanup registration,
791
+ * node selection, connection, and IP verification. The simplest way to use the SDK.
792
+ *
793
+ * @param {object} opts
794
+ * @param {string} opts.mnemonic - BIP39 wallet mnemonic (12 or 24 words)
795
+ * @param {string[]} [opts.countries] - Preferred countries (e.g. ['DE', 'NL'])
796
+ * @param {string} [opts.serviceType] - 'wireguard' | 'v2ray' | null (both)
797
+ * @param {number} [opts.maxAttempts=3] - Max nodes to try
798
+ * @param {function} [opts.onProgress] - Progress callback
799
+ * @param {function} [opts.log] - Logger function
800
+ * @returns {Promise<ConnectResult & { vpnIp?: string }>}
801
+ */
802
+ export async function quickConnect(opts) {
803
+ if (!opts?.mnemonic) {
804
+ throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'quickConnect() requires opts.mnemonic');
805
+ }
806
+
807
+ // Auto-register cleanup (idempotent)
808
+ registerCleanupHandlers();
809
+
810
+ // Check dependencies
811
+ const deps = verifyDependencies({ v2rayExePath: opts.v2rayExePath });
812
+ if (!deps.ok) {
813
+ const logFn = opts.log || console.warn;
814
+ for (const err of deps.errors) logFn(`[quickConnect] Warning: ${err}`);
815
+ }
816
+
817
+ // Connect with auto-fallback
818
+ const connectOpts = {
819
+ ...opts,
820
+ fullTunnel: opts.fullTunnel !== false, // default true
821
+ systemProxy: opts.systemProxy !== false, // default true for V2Ray
822
+ killSwitch: opts.killSwitch === true,
823
+ };
824
+
825
+ const result = await connectAuto(connectOpts);
826
+
827
+ // Verify IP changed
828
+ try {
829
+ const { vpnIp } = await verifyConnection({ timeoutMs: 6000 });
830
+ result.vpnIp = vpnIp;
831
+ } catch { /* IP check is best-effort */ }
832
+
833
+ return result;
834
+ }
835
+
836
+ // ─── ConnectOptions Builder (v25) ────────────────────────────────────────────
837
+
838
+ /**
839
+ * Create a reusable base config. Override per-call with .with().
840
+ * @param {object} baseOpts - Default ConnectOptions (mnemonic, rpcUrl, etc.)
841
+ * @returns {{ ...baseOpts, with(overrides): object }}
842
+ */
843
+ export function createConnectConfig(baseOpts) {
844
+ const config = { ...baseOpts };
845
+ config.with = (overrides) => ({ ...config, ...overrides });
846
+ // Remove .with from spread results (non-enumerable)
847
+ Object.defineProperty(config, 'with', { enumerable: false });
848
+ return Object.freeze(config);
849
+ }