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,884 @@
1
+ /**
2
+ * Sentinel AI Path — Zero-Config VPN Connection
3
+ *
4
+ * One function call: await connect({ mnemonic }) -> connected
5
+ *
6
+ * This module wraps the full Sentinel SDK into the simplest possible
7
+ * interface for AI agents. No config files, no setup — just connect.
8
+ *
9
+ * AGENT FLOW (7 steps, each logged):
10
+ * STEP 1/7 Environment — check OS, V2Ray, WireGuard, admin
11
+ * STEP 2/7 Wallet — derive address, connect to chain
12
+ * STEP 3/7 Balance — verify sufficient P2P before paying
13
+ * STEP 4/7 Node — select + validate target node
14
+ * STEP 5/7 Session — broadcast TX, create on-chain session
15
+ * STEP 6/7 Tunnel — handshake + install WireGuard/V2Ray
16
+ * STEP 7/7 Verify — confirm IP changed, traffic flows
17
+ */
18
+
19
+ import {
20
+ connectAuto,
21
+ connectDirect,
22
+ disconnect as sdkDisconnect,
23
+ isConnected,
24
+ getStatus,
25
+ registerCleanupHandlers,
26
+ verifyConnection,
27
+ verifyDependencies,
28
+ formatP2P,
29
+ events,
30
+ createWallet as sdkCreateWallet,
31
+ createClient,
32
+ getBalance as sdkGetBalance,
33
+ tryWithFallback,
34
+ RPC_ENDPOINTS,
35
+ // v1.5.0: RPC queries (protobuf, ~10x faster than LCD for balance checks)
36
+ createRpcQueryClientWithFallback,
37
+ rpcQueryBalance,
38
+ // v1.5.0: Typed event parsers (replaces string matching for session ID extraction)
39
+ extractSessionIdTyped,
40
+ NodeEventCreateSession,
41
+ // v1.5.0: TYPE_URLS constants (canonical type URL strings)
42
+ TYPE_URLS,
43
+ } from '../index.js';
44
+
45
+ // Use native fetch (Node 20+) for IP check — no axios dependency needed
46
+ // The SDK handles axios adapter internally for tunnel traffic
47
+
48
+ // ─── Constants ───────────────────────────────────────────────────────────────
49
+
50
+ const IP_CHECK_URL = 'https://api.ipify.org?format=json';
51
+ const IP_CHECK_TIMEOUT = 10000;
52
+ const MIN_BALANCE_UDVPN = 5_000_000; // 5 P2P — realistic minimum for cheapest node (~4 P2P) + gas
53
+
54
+ // ─── State ───────────────────────────────────────────────────────────────────
55
+
56
+ let _cleanupRegistered = false;
57
+ let _lastConnectResult = null;
58
+ let _connectTimings = {};
59
+
60
+ // ─── Agent Logger ───────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Structured step logger for autonomous agents.
64
+ * Each step prints a numbered phase with timestamp.
65
+ * Agents can parse these lines programmatically.
66
+ */
67
+ function agentLog(step, total, phase, msg) {
68
+ const ts = new Date().toISOString();
69
+ console.log(`[${ts}] [STEP ${step}/${total}] [${phase}] ${msg}`);
70
+ }
71
+
72
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Ensure cleanup handlers are registered (idempotent).
76
+ * Handles SIGINT, SIGTERM, uncaught exceptions — tears down tunnels on exit.
77
+ */
78
+ function ensureCleanup() {
79
+ if (_cleanupRegistered) return;
80
+ registerCleanupHandlers();
81
+ _cleanupRegistered = true;
82
+ }
83
+
84
+ /**
85
+ * Ensure axios uses Node.js HTTP adapter (not fetch) for Node 20+.
86
+ * Without this, SOCKS proxy and tunnel traffic silently fails.
87
+ * Lazy-imports axios from the SDK's node_modules.
88
+ */
89
+ async function ensureAxiosAdapter() {
90
+ try {
91
+ const axios = (await import('axios')).default;
92
+ if (axios.defaults.adapter !== 'http') {
93
+ axios.defaults.adapter = 'http';
94
+ }
95
+ } catch {
96
+ // axios not available — SDK will handle this during connect
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Check the public IP through the VPN tunnel to confirm it changed.
102
+ * For WireGuard: native fetch routes through the tunnel automatically.
103
+ * For V2Ray: must use SOCKS5 proxy — native fetch ignores SOCKS5.
104
+ * Returns the IP string or null if the check fails.
105
+ */
106
+ async function checkVpnIp(socksPort) {
107
+ try {
108
+ if (socksPort) {
109
+ // V2Ray: route IP check through SOCKS5 proxy
110
+ // Use SDK's checkVpnIpViaSocks which has proper module resolution
111
+ const { checkVpnIpViaSocks } = await import('../index.js');
112
+ if (typeof checkVpnIpViaSocks === 'function') {
113
+ return await checkVpnIpViaSocks(socksPort, IP_CHECK_TIMEOUT);
114
+ }
115
+ // Fallback: use Node.js module resolution (works in every layout)
116
+ const axios = (await import('axios')).default;
117
+ const { SocksProxyAgent } = await import('socks-proxy-agent');
118
+ const agent = new SocksProxyAgent(`socks5h://127.0.0.1:${socksPort}`);
119
+ const res = await axios.get(IP_CHECK_URL, {
120
+ httpAgent: agent, httpsAgent: agent,
121
+ timeout: IP_CHECK_TIMEOUT, adapter: 'http',
122
+ });
123
+ return res.data?.ip || null;
124
+ }
125
+ // WireGuard: native fetch routes through tunnel
126
+ const res = await fetch(IP_CHECK_URL, {
127
+ signal: AbortSignal.timeout(IP_CHECK_TIMEOUT),
128
+ });
129
+ const data = await res.json();
130
+ return data?.ip || null;
131
+ } catch (err) {
132
+ // IP check is non-critical — tunnel may work but ipify may be blocked
133
+ if (err?.code === 'ERR_MODULE_NOT_FOUND') {
134
+ console.warn('[sentinel-ai] IP check skipped: missing dependency —', err.message?.split("'")[1] || 'unknown');
135
+ }
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Convert SDK errors to human-readable messages with machine-readable nextAction.
142
+ * AI agents get clean, actionable error strings instead of stack traces.
143
+ */
144
+ function humanError(err) {
145
+ const code = err?.code || 'UNKNOWN';
146
+ const msg = err?.message || String(err);
147
+
148
+ // Map common error codes to plain-English messages + next action for agent
149
+ const messages = {
150
+ INVALID_MNEMONIC: {
151
+ message: 'Invalid mnemonic — must be a 12 or 24 word BIP39 phrase.',
152
+ nextAction: 'create_wallet',
153
+ },
154
+ INSUFFICIENT_BALANCE: {
155
+ message: 'Wallet has insufficient P2P tokens. Fund your wallet first.',
156
+ nextAction: 'fund_wallet',
157
+ },
158
+ ALREADY_CONNECTED: {
159
+ message: 'Already connected to VPN. Call disconnect() first.',
160
+ nextAction: 'disconnect',
161
+ },
162
+ NODE_NOT_FOUND: {
163
+ message: 'Node not found or offline. Try a different node or use connectAuto.',
164
+ nextAction: 'try_different_node',
165
+ },
166
+ NODE_NO_UDVPN: {
167
+ message: 'Node does not accept P2P token payments.',
168
+ nextAction: 'try_different_node',
169
+ },
170
+ WG_NO_CONNECTIVITY: {
171
+ message: 'WireGuard tunnel installed but no traffic flows. Try a different node.',
172
+ nextAction: 'try_different_node',
173
+ },
174
+ V2RAY_NOT_FOUND: {
175
+ message: 'V2Ray binary not found. Run setup first: node setup.js',
176
+ nextAction: 'run_setup',
177
+ },
178
+ HANDSHAKE_FAILED: {
179
+ message: 'Handshake with node failed. The node may be overloaded — try another.',
180
+ nextAction: 'try_different_node',
181
+ },
182
+ SESSION_EXTRACT_FAILED: {
183
+ message: 'Session creation TX succeeded but session ID could not be extracted.',
184
+ nextAction: 'retry',
185
+ },
186
+ ALL_NODES_FAILED: {
187
+ message: 'All candidate nodes failed to connect.',
188
+ nextAction: 'try_different_country',
189
+ },
190
+ ABORTED: {
191
+ message: 'Connection was cancelled.',
192
+ nextAction: 'none',
193
+ },
194
+ };
195
+
196
+ const entry = messages[code];
197
+ if (entry) return entry;
198
+ return { message: `Connection failed: ${msg}`, nextAction: 'retry' };
199
+ }
200
+
201
+ /**
202
+ * Pre-validate balance before any connection attempt.
203
+ * Returns { address, udvpn, p2p, sufficient }.
204
+ */
205
+ async function preValidateBalance(mnemonic) {
206
+ try {
207
+ const { wallet, account } = await sdkCreateWallet(mnemonic);
208
+
209
+ // v1.5.0: Try RPC query first (protobuf, ~10x faster — no signing client needed)
210
+ try {
211
+ const rpcClient = await createRpcQueryClientWithFallback();
212
+ const coin = await rpcQueryBalance(rpcClient, account.address, 'udvpn');
213
+ const udvpn = parseInt(coin.amount, 10) || 0;
214
+ return {
215
+ address: account.address,
216
+ udvpn,
217
+ p2p: formatP2P(udvpn),
218
+ sufficient: udvpn >= MIN_BALANCE_UDVPN,
219
+ };
220
+ } catch {
221
+ // RPC failed — fall back to signing client
222
+ }
223
+
224
+ // Fallback: signing client + sdkGetBalance (LCD-based)
225
+ const { result: client } = await tryWithFallback(
226
+ RPC_ENDPOINTS,
227
+ async (url) => createClient(url, wallet),
228
+ 'RPC connect (balance pre-check)',
229
+ );
230
+ const bal = await sdkGetBalance(client, account.address);
231
+ return {
232
+ address: account.address,
233
+ udvpn: bal.udvpn,
234
+ p2p: formatP2P(bal.udvpn),
235
+ sufficient: bal.udvpn >= MIN_BALANCE_UDVPN,
236
+ };
237
+ } catch {
238
+ // Balance check failed — let connect() handle it downstream
239
+ return { address: null, udvpn: 0, p2p: '0 P2P', sufficient: false };
240
+ }
241
+ }
242
+
243
+ // ─── connect() ───────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Connect to Sentinel dVPN. The ONE function an AI agent needs.
247
+ *
248
+ * Every step is logged with numbered phases (STEP 1/7 through STEP 7/7)
249
+ * so an autonomous agent can track progress and diagnose failures.
250
+ *
251
+ * @param {object} opts
252
+ * @param {string} opts.mnemonic - BIP39 mnemonic (12 or 24 words)
253
+ * @param {string} [opts.country] - Preferred country code (e.g. 'US', 'DE')
254
+ * @param {string} [opts.nodeAddress] - Specific node (sentnode1...). Skips auto-pick.
255
+ * @param {string} [opts.dns] - DNS preset: 'google', 'cloudflare', 'hns'
256
+ * @param {string} [opts.protocol] - Preferred protocol: 'wireguard' or 'v2ray'
257
+ * @param {function} [opts.onProgress] - Progress callback: (stage, message) => void
258
+ * @param {number} [opts.timeout] - Connection timeout in ms (default: 120000 — 2 minutes)
259
+ * @param {boolean} [opts.silent] - If true, suppress step-by-step console output
260
+ * @returns {Promise<{
261
+ * sessionId: string,
262
+ * protocol: string,
263
+ * nodeAddress: string,
264
+ * country: string|null,
265
+ * city: string|null,
266
+ * moniker: string|null,
267
+ * socksPort: number|null,
268
+ * socksAuth: object|null,
269
+ * dryRun: boolean,
270
+ * ip: string|null,
271
+ * walletAddress: string,
272
+ * balance: { before: string, after: string|null },
273
+ * cost: { estimated: string },
274
+ * timing: { totalMs: number, phases: object },
275
+ * }>}
276
+ */
277
+ export async function connect(opts = {}) {
278
+ if (!opts || typeof opts !== 'object') {
279
+ throw new Error('connect() requires an options object with at least { mnemonic }');
280
+ }
281
+ if (!opts.mnemonic || typeof opts.mnemonic !== 'string') {
282
+ throw new Error('connect() requires a mnemonic string (12 or 24 word BIP39 phrase)');
283
+ }
284
+
285
+ const silent = opts.silent === true;
286
+ const log = silent ? () => {} : agentLog;
287
+ const totalSteps = 7;
288
+ const timings = {};
289
+ const connectStart = Date.now();
290
+
291
+ // ── STEP 1/7: Environment ─────────────────────────────────────────────────
292
+
293
+ let t0 = Date.now();
294
+ log(1, totalSteps, 'ENVIRONMENT', 'Checking OS, tunnel binaries, admin privileges...');
295
+
296
+ await ensureAxiosAdapter();
297
+ ensureCleanup();
298
+
299
+ // Detect environment for agent visibility
300
+ let envInfo = { os: process.platform, admin: false, v2ray: false, wireguard: false };
301
+ try {
302
+ const { getEnvironment } = await import('./environment.js');
303
+ const env = getEnvironment();
304
+ envInfo = {
305
+ os: env.os,
306
+ admin: env.admin,
307
+ v2ray: env.v2ray?.available || false,
308
+ wireguard: env.wireguard?.available || false,
309
+ v2rayPath: env.v2ray?.path || null,
310
+ };
311
+ } catch { /* environment detection failed */ }
312
+
313
+ log(1, totalSteps, 'ENVIRONMENT', `OS=${envInfo.os} | admin=${envInfo.admin} | v2ray=${envInfo.v2ray} | wireguard=${envInfo.wireguard}`);
314
+ timings.environment = Date.now() - t0;
315
+
316
+ // ── STEP 2/7: Wallet ──────────────────────────────────────────────────────
317
+
318
+ t0 = Date.now();
319
+ log(2, totalSteps, 'WALLET', 'Deriving wallet address from mnemonic...');
320
+
321
+ // We derive address early for agent visibility (before SDK does it internally)
322
+ let walletAddress = null;
323
+ try {
324
+ const { account } = await sdkCreateWallet(opts.mnemonic);
325
+ walletAddress = account.address;
326
+ log(2, totalSteps, 'WALLET', `Address: ${walletAddress}`);
327
+ } catch (err) {
328
+ log(2, totalSteps, 'WALLET', `Failed: ${err.message}`);
329
+ throw new Error('Invalid mnemonic — wallet derivation failed');
330
+ }
331
+ timings.wallet = Date.now() - t0;
332
+
333
+ // ── STEP 3/7: Balance Pre-Check ───────────────────────────────────────────
334
+
335
+ t0 = Date.now();
336
+ log(3, totalSteps, 'BALANCE', `Checking balance for ${walletAddress}...`);
337
+
338
+ const balCheck = await preValidateBalance(opts.mnemonic);
339
+ log(3, totalSteps, 'BALANCE', `Balance: ${balCheck.p2p} | Sufficient: ${balCheck.sufficient}`);
340
+
341
+ if (!balCheck.sufficient && !opts.dryRun) {
342
+ const err = new Error(`Insufficient balance: ${balCheck.p2p}. Need at least ${formatP2P(MIN_BALANCE_UDVPN)}. Fund address: ${walletAddress}`);
343
+ err.code = 'INSUFFICIENT_BALANCE';
344
+ err.nextAction = 'fund_wallet';
345
+ err.details = { address: walletAddress, balance: balCheck.p2p, minimum: formatP2P(MIN_BALANCE_UDVPN) };
346
+ throw err;
347
+ }
348
+ timings.balance = Date.now() - t0;
349
+
350
+ // ── STEP 4/7: Node Selection ──────────────────────────────────────────────
351
+
352
+ t0 = Date.now();
353
+
354
+ // Build SDK options — forward ALL documented options to the underlying SDK.
355
+ const sdkOpts = {
356
+ mnemonic: opts.mnemonic,
357
+ onProgress: (stage, msg) => {
358
+ if (opts.onProgress) opts.onProgress(stage, msg);
359
+ const stageMap = {
360
+ 'wallet': 2, 'node-check': 4, 'validate': 4,
361
+ 'session': 5, 'handshake': 6, 'tunnel': 6,
362
+ 'verify': 7, 'dry-run': 7,
363
+ };
364
+ const step = stageMap[stage] || 5;
365
+ const phase = stage.toUpperCase().replace('-', '_');
366
+ if (!silent) agentLog(step, totalSteps, phase, msg);
367
+ // BUG-2 fix: capture node metadata from progress callback
368
+ // Format: "MonkerName (protocol) - City, Country"
369
+ if (stage === 'node-check' && msg && !sdkOpts._discoveredNode) {
370
+ const match = msg.match(/^(.+?)\s+\((\w+)\)\s+-\s+(.+?),\s+(.+)$/);
371
+ if (match) {
372
+ sdkOpts._discoveredNode = {
373
+ moniker: match[1],
374
+ serviceType: match[2],
375
+ city: match[3],
376
+ country: match[4],
377
+ };
378
+ }
379
+ }
380
+ },
381
+ log: (msg) => {
382
+ if (opts.onProgress) opts.onProgress('log', msg);
383
+ },
384
+ };
385
+
386
+ // DNS
387
+ if (opts.dns) sdkOpts.dns = opts.dns;
388
+
389
+ // Protocol preference — search BOTH protocols when not specified
390
+ if (opts.protocol === 'wireguard') sdkOpts.serviceType = 'wireguard';
391
+ else if (opts.protocol === 'v2ray') sdkOpts.serviceType = 'v2ray';
392
+ // When no protocol specified: do NOT set serviceType — let SDK try all node types
393
+ // This ensures both WireGuard AND V2Ray nodes are candidates
394
+
395
+ // Session pricing
396
+ if (opts.gigabytes && opts.gigabytes > 0) sdkOpts.gigabytes = opts.gigabytes;
397
+ if (opts.hours && opts.hours > 0) sdkOpts.hours = opts.hours;
398
+
399
+ // Tunnel options
400
+ if (opts.fullTunnel === false) sdkOpts.fullTunnel = false;
401
+ if (opts.killSwitch === true) sdkOpts.killSwitch = true;
402
+ if (opts.systemProxy !== undefined) sdkOpts.systemProxy = opts.systemProxy;
403
+
404
+ // Split tunnel — WireGuard: route only specific IPs through VPN
405
+ if (opts.splitIPs && Array.isArray(opts.splitIPs) && opts.splitIPs.length > 0) {
406
+ sdkOpts.splitIPs = opts.splitIPs;
407
+ sdkOpts.fullTunnel = false;
408
+ }
409
+
410
+ // V2Ray SOCKS5 auth
411
+ if (opts.socksAuth === true) sdkOpts.socksAuth = true;
412
+
413
+ // V2Ray binary path
414
+ if (opts.v2rayExePath) {
415
+ sdkOpts.v2rayExePath = opts.v2rayExePath;
416
+ } else if (envInfo.v2rayPath) {
417
+ sdkOpts.v2rayExePath = envInfo.v2rayPath;
418
+ }
419
+
420
+ // Max connection attempts
421
+ if (opts.maxAttempts && opts.maxAttempts > 0) sdkOpts.maxAttempts = opts.maxAttempts;
422
+
423
+ // Dry run
424
+ if (opts.dryRun === true) sdkOpts.dryRun = true;
425
+
426
+ // Force new session
427
+ if (opts.forceNewSession === true) sdkOpts.forceNewSession = true;
428
+
429
+ // AbortController
430
+ const timeoutMs = (opts.timeout && opts.timeout > 0) ? opts.timeout : 120000;
431
+ const ac = new AbortController();
432
+ const timeoutId = setTimeout(() => ac.abort(), timeoutMs);
433
+ if (opts.signal) {
434
+ if (opts.signal.aborted) { ac.abort(); } else {
435
+ opts.signal.addEventListener('abort', () => ac.abort(), { once: true });
436
+ }
437
+ }
438
+ sdkOpts.signal = ac.signal;
439
+
440
+ // ── Country-aware node discovery ──────────────────────────────────────
441
+ // When a country is specified, connectAuto's default probe of 9 random nodes
442
+ // is too small to find nodes in rare countries (e.g., Singapore = 2 of 1037).
443
+ // Instead, we discover nodes in that country first, then connectDirect to one.
444
+ // This probes up to 200 nodes to find country matches, searching BOTH protocols.
445
+
446
+ let resolvedNodeAddress = opts.nodeAddress || null;
447
+
448
+ if (!resolvedNodeAddress && opts.country) {
449
+ const countryUpper = opts.country.toUpperCase();
450
+ log(4, totalSteps, 'NODE', `Discovering nodes in ${countryUpper} (probing both WireGuard + V2Ray)...`);
451
+
452
+ try {
453
+ const { queryOnlineNodes, filterNodes, COUNTRY_MAP } = await import('../index.js');
454
+
455
+ // Probe a large sample WITHOUT protocol filter — find ALL country matches
456
+ const probeCount = Math.max(200, (opts.maxAttempts || 3) * 50);
457
+ const allProbed = await queryOnlineNodes({
458
+ maxNodes: probeCount,
459
+ onNodeProbed: ({ total, probed, online }) => {
460
+ if (probed % 50 === 0 || probed === total) {
461
+ log(4, totalSteps, 'NODE', `Probed ${probed}/${total} nodes, ${online} online...`);
462
+ }
463
+ },
464
+ });
465
+
466
+ // Resolve country: filterNodes uses includes() on country NAME, not ISO code.
467
+ // If agent passed "SG", we need "Singapore" for filterNodes to match.
468
+ // Build reverse map: ISO code → country name
469
+ let countryFilter = countryUpper;
470
+ if (COUNTRY_MAP && countryUpper.length === 2) {
471
+ // COUNTRY_MAP is { 'singapore': 'SG', ... } — reverse lookup
472
+ for (const [name, code] of Object.entries(COUNTRY_MAP)) {
473
+ if (code === countryUpper) {
474
+ countryFilter = name; // "singapore" — filterNodes lowercases both sides
475
+ break;
476
+ }
477
+ }
478
+ }
479
+
480
+ // Filter by country — use the resolved name (e.g., "singapore" not "SG")
481
+ let countryNodes = filterNodes(allProbed, { country: countryFilter });
482
+ let wgNodes = countryNodes.filter(n => n.serviceType === 'wireguard');
483
+ let v2Nodes = countryNodes.filter(n => n.serviceType === 'v2ray');
484
+
485
+ log(4, totalSteps, 'NODE', `Found ${countryNodes.length} nodes in ${countryUpper}: ${wgNodes.length} WireGuard, ${v2Nodes.length} V2Ray`);
486
+
487
+ // If initial sample missed the country, do a FULL scan of all nodes.
488
+ // Rare countries (e.g., Singapore = 2 of 1037) need the full network scan.
489
+ if (countryNodes.length === 0) {
490
+ log(4, totalSteps, 'NODE', `${countryUpper} not in initial sample. Scanning ALL nodes (this takes ~2 min)...`);
491
+ const fullProbed = await queryOnlineNodes({
492
+ maxNodes: 5000, // All nodes
493
+ onNodeProbed: ({ total, probed, online }) => {
494
+ if (probed % 100 === 0 || probed === total) {
495
+ log(4, totalSteps, 'NODE', `Full scan: ${probed}/${total} probed, ${online} online...`);
496
+ }
497
+ },
498
+ });
499
+ countryNodes = filterNodes(fullProbed, { country: countryFilter });
500
+ wgNodes = countryNodes.filter(n => n.serviceType === 'wireguard');
501
+ v2Nodes = countryNodes.filter(n => n.serviceType === 'v2ray');
502
+ log(4, totalSteps, 'NODE', `Full scan: ${countryNodes.length} nodes in ${countryUpper}: ${wgNodes.length} WireGuard, ${v2Nodes.length} V2Ray`);
503
+ }
504
+
505
+ if (countryNodes.length > 0) {
506
+ // Pick best node: prefer requested protocol, then WireGuard (faster), then V2Ray
507
+ let picked;
508
+ if (opts.protocol === 'wireguard' && wgNodes.length > 0) {
509
+ picked = wgNodes[0]; // Already sorted by quality score
510
+ } else if (opts.protocol === 'v2ray' && v2Nodes.length > 0) {
511
+ picked = v2Nodes[0];
512
+ } else if (wgNodes.length > 0 && envInfo.admin) {
513
+ picked = wgNodes[0]; // WireGuard preferred when admin
514
+ } else if (v2Nodes.length > 0) {
515
+ picked = v2Nodes[0];
516
+ } else {
517
+ picked = countryNodes[0];
518
+ }
519
+
520
+ resolvedNodeAddress = picked.address;
521
+ // Store discovered node metadata for the result object
522
+ sdkOpts._discoveredNode = {
523
+ country: picked.country || null,
524
+ city: picked.city || null,
525
+ moniker: picked.moniker || null,
526
+ serviceType: picked.serviceType || null,
527
+ qualityScore: picked.qualityScore || 0,
528
+ };
529
+ log(4, totalSteps, 'NODE', `Selected: ${picked.address} (${picked.serviceType}) — ${picked.moniker || 'unnamed'}, ${picked.country}, score=${picked.qualityScore}`);
530
+ } else {
531
+ log(4, totalSteps, 'NODE', `No nodes found in ${countryUpper}. Falling back to global auto-select.`);
532
+ }
533
+ } catch (err) {
534
+ log(4, totalSteps, 'NODE', `Country discovery failed: ${err.message}. Falling back to auto-select.`);
535
+ }
536
+ } else if (!resolvedNodeAddress) {
537
+ log(4, totalSteps, 'NODE', 'Auto-selecting best available node (all countries, both protocols)...');
538
+ } else {
539
+ log(4, totalSteps, 'NODE', `Direct node: ${resolvedNodeAddress}`);
540
+ }
541
+
542
+ timings.nodeSelection = Date.now() - t0;
543
+
544
+ // ── STEP 5/7 + 6/7: Session + Tunnel (handled by SDK internally) ─────────
545
+
546
+ t0 = Date.now();
547
+ log(5, totalSteps, 'SESSION', 'Broadcasting session transaction...');
548
+
549
+ try {
550
+ let result;
551
+
552
+ if (resolvedNodeAddress) {
553
+ // Direct connection — either user specified nodeAddress or country discovery found one
554
+ sdkOpts.nodeAddress = resolvedNodeAddress;
555
+ result = await connectDirect(sdkOpts);
556
+ } else {
557
+ // No country filter or country discovery found nothing — auto-select globally
558
+ // Use higher maxAttempts to search more nodes
559
+ if (!sdkOpts.maxAttempts) sdkOpts.maxAttempts = 5;
560
+ result = await connectAuto(sdkOpts);
561
+ }
562
+
563
+ timings.sessionAndTunnel = Date.now() - t0;
564
+
565
+ // ── STEP 7/7: Verify ──────────────────────────────────────────────────
566
+
567
+ t0 = Date.now();
568
+ log(7, totalSteps, 'VERIFY', 'Checking VPN IP through tunnel...');
569
+
570
+ const ip = await checkVpnIp(result.socksPort || null);
571
+ log(7, totalSteps, 'VERIFY', ip ? `VPN IP: ${ip}` : 'IP check failed (tunnel may still work)');
572
+
573
+ timings.verify = Date.now() - t0;
574
+ timings.total = Date.now() - connectStart;
575
+
576
+ // ── Post-connect balance check (single RPC call — fixes BUG-3) ─────
577
+
578
+ let balanceAfter = null;
579
+ let costUdvpn = 0;
580
+ let costFormatted = 'unknown';
581
+ try {
582
+ const postBal = await preValidateBalance(opts.mnemonic);
583
+ balanceAfter = postBal.p2p;
584
+ costUdvpn = Math.max(0, balCheck.udvpn - postBal.udvpn);
585
+ costFormatted = formatP2P(costUdvpn);
586
+ } catch { /* non-critical — tunnel works even if balance check fails */ }
587
+
588
+ // ── Build agent-friendly return object ───────────────────────────────
589
+
590
+ // Pull country/city/moniker from: discovered node metadata > SDK result > onProgress capture
591
+ const discovered = sdkOpts._discoveredNode || {};
592
+
593
+ const output = {
594
+ sessionId: String(result.sessionId),
595
+ protocol: result.serviceType || discovered.serviceType || 'unknown',
596
+ nodeAddress: result.nodeAddress || resolvedNodeAddress || 'unknown',
597
+ country: result.nodeLocation?.country || discovered.country || null,
598
+ city: result.nodeLocation?.city || discovered.city || null,
599
+ moniker: result.nodeMoniker || discovered.moniker || null,
600
+ socksPort: result.socksPort || null,
601
+ socksAuth: result.socksAuth || null,
602
+ dryRun: result.dryRun || false,
603
+ ip,
604
+ walletAddress: walletAddress || balCheck.address,
605
+ balance: {
606
+ before: balCheck.p2p,
607
+ after: balanceAfter,
608
+ },
609
+ cost: {
610
+ udvpn: costUdvpn,
611
+ p2p: costFormatted,
612
+ },
613
+ timing: {
614
+ totalMs: timings.total,
615
+ totalFormatted: `${(timings.total / 1000).toFixed(1)}s`,
616
+ phases: { ...timings },
617
+ },
618
+ };
619
+
620
+ _lastConnectResult = output;
621
+ _lastConnectResult._connectedAt = Date.now(); // BUG-4 fix: store actual connect timestamp for uptime
622
+ _connectTimings = timings;
623
+
624
+ // ── Final summary ──────────────────────────────────────────────────
625
+
626
+ log(7, totalSteps, 'COMPLETE', [
627
+ `Session=${output.sessionId}`,
628
+ `Protocol=${output.protocol}`,
629
+ `Node=${output.nodeAddress}`,
630
+ output.country ? `Country=${output.country}` : null,
631
+ `IP=${output.ip || 'unknown'}`,
632
+ `Time=${output.timing.totalFormatted}`,
633
+ `Balance=${output.balance.before} → ${output.balance.after || '?'}`,
634
+ ].filter(Boolean).join(' | '));
635
+
636
+ return output;
637
+ } catch (err) {
638
+ timings.total = Date.now() - connectStart;
639
+ const { message, nextAction } = humanError(err);
640
+ const wrapped = new Error(message);
641
+ wrapped.code = err?.code || 'UNKNOWN';
642
+ wrapped.nextAction = nextAction;
643
+ wrapped.details = err?.details || null;
644
+ wrapped.timing = { totalMs: timings.total, phases: { ...timings } };
645
+
646
+ log(5, totalSteps, 'FAILED', `${wrapped.code}: ${message} → nextAction: ${nextAction}`);
647
+ throw wrapped;
648
+ } finally {
649
+ if (timeoutId) clearTimeout(timeoutId);
650
+ }
651
+ }
652
+
653
+ // ─── disconnect() ────────────────────────────────────────────────────────────
654
+
655
+ /**
656
+ * Disconnect from VPN. Tears down tunnel, cleans up system state.
657
+ * Returns session cost and remaining balance for agent accounting.
658
+ *
659
+ * @returns {Promise<{
660
+ * disconnected: boolean,
661
+ * sessionId: string|null,
662
+ * balance: string|null,
663
+ * timing: { connectedMs: number|null },
664
+ * }>}
665
+ */
666
+ export async function disconnect() {
667
+ const prevResult = _lastConnectResult;
668
+ const sessionId = prevResult?.sessionId || null;
669
+
670
+ agentLog(1, 1, 'DISCONNECT', `Ending session${sessionId ? ` ${sessionId}` : ''}...`);
671
+
672
+ try {
673
+ await sdkDisconnect();
674
+
675
+ // Check remaining balance after disconnect
676
+ let balance = null;
677
+ if (prevResult?.walletAddress) {
678
+ try {
679
+ // Re-derive from stored result isn't possible without mnemonic.
680
+ // Listen for the session-end event from SDK instead.
681
+ balance = prevResult.balance?.after || null;
682
+ } catch { /* non-critical */ }
683
+ }
684
+
685
+ const output = {
686
+ disconnected: true,
687
+ sessionId,
688
+ balance,
689
+ timing: {
690
+ connectedMs: prevResult?._connectedAt
691
+ ? Date.now() - prevResult._connectedAt
692
+ : null,
693
+ setupMs: prevResult?.timing?.totalMs || null,
694
+ },
695
+ };
696
+
697
+ agentLog(1, 1, 'DISCONNECT', `Done. Session ${sessionId || 'unknown'} ended.`);
698
+
699
+ _lastConnectResult = null;
700
+ _connectTimings = {};
701
+ return output;
702
+ } catch (err) {
703
+ _lastConnectResult = null;
704
+ _connectTimings = {};
705
+ throw new Error(`Disconnect failed: ${err.message}`);
706
+ }
707
+ }
708
+
709
+ // ─── status() ────────────────────────────────────────────────────────────────
710
+
711
+ /**
712
+ * Get current VPN connection status.
713
+ * Returns everything an agent needs to assess the connection.
714
+ *
715
+ * @returns {{
716
+ * connected: boolean,
717
+ * sessionId?: string,
718
+ * protocol?: string,
719
+ * nodeAddress?: string,
720
+ * country?: string,
721
+ * city?: string,
722
+ * socksPort?: number,
723
+ * uptimeMs?: number,
724
+ * uptimeFormatted?: string,
725
+ * ip?: string|null,
726
+ * balance?: { before: string, after: string|null },
727
+ * }}
728
+ */
729
+ export function status() {
730
+ const sdkStatus = getStatus();
731
+
732
+ if (!sdkStatus) {
733
+ return { connected: false };
734
+ }
735
+
736
+ return {
737
+ connected: true,
738
+ sessionId: sdkStatus.sessionId || null,
739
+ protocol: sdkStatus.serviceType || null,
740
+ nodeAddress: sdkStatus.nodeAddress || null,
741
+ country: _lastConnectResult?.country || null,
742
+ city: _lastConnectResult?.city || null,
743
+ socksPort: sdkStatus.socksPort || null,
744
+ uptimeMs: sdkStatus.uptimeMs || 0,
745
+ uptimeFormatted: sdkStatus.uptimeFormatted || '0s',
746
+ ip: _lastConnectResult?.ip || null,
747
+ balance: _lastConnectResult?.balance || null,
748
+ };
749
+ }
750
+
751
+ // ─── isVpnActive() ──────────────────────────────────────────────────────────
752
+
753
+ /**
754
+ * Quick boolean check: is the VPN tunnel active right now?
755
+ *
756
+ * @returns {boolean}
757
+ */
758
+ export function isVpnActive() {
759
+ return isConnected();
760
+ }
761
+
762
+ // ─── verify() ───────────────────────────────────────────────────────────────
763
+
764
+ /**
765
+ * Verify the VPN connection is actually working.
766
+ * Checks: tunnel is up, traffic flows, IP has changed.
767
+ *
768
+ * @returns {Promise<{connected: boolean, ip: string|null, verified: boolean}>}
769
+ */
770
+ export async function verify() {
771
+ if (!isConnected()) {
772
+ return { connected: false, ip: null, verified: false };
773
+ }
774
+
775
+ // Check IP through tunnel with latency measurement
776
+ const socksPort = _lastConnectResult?.socksPort || null;
777
+ const t0 = Date.now();
778
+ const ip = await checkVpnIp(socksPort);
779
+ const latency = Date.now() - t0;
780
+
781
+ // Try SDK's built-in verification if available
782
+ let sdkVerified = false;
783
+ try {
784
+ if (typeof verifyConnection === 'function') {
785
+ const result = await verifyConnection();
786
+ sdkVerified = !!result;
787
+ }
788
+ } catch {
789
+ // verifyConnection may not exist or may fail — IP check is sufficient
790
+ }
791
+
792
+ return {
793
+ connected: true,
794
+ ip,
795
+ verified: ip !== null || sdkVerified,
796
+ latency,
797
+ protocol: _lastConnectResult?.protocol || null,
798
+ nodeAddress: _lastConnectResult?.nodeAddress || null,
799
+ };
800
+ }
801
+
802
+ // ─── verifySplitTunnel() ─────────────────────────────────────────────────────
803
+
804
+ /**
805
+ * Verify split tunneling is working correctly.
806
+ * For V2Ray: confirms SOCKS5 proxy routes traffic through VPN while direct traffic bypasses.
807
+ * For WireGuard: confirms tunnel is active (split tunnel verification requires known static IPs).
808
+ *
809
+ * IMPORTANT: Uses axios + SocksProxyAgent — NOT native fetch (which ignores SOCKS5).
810
+ *
811
+ * @returns {Promise<{splitTunnel: boolean, proxyIp: string|null, directIp: string|null, protocol: string|null}>}
812
+ */
813
+ export async function verifySplitTunnel() {
814
+ if (!isConnected()) {
815
+ return { splitTunnel: false, proxyIp: null, directIp: null, protocol: null };
816
+ }
817
+
818
+ const socksPort = _lastConnectResult?.socksPort || null;
819
+ const protocol = _lastConnectResult?.protocol || null;
820
+
821
+ // Get direct IP (bypasses VPN)
822
+ let directIp = null;
823
+ try {
824
+ if (socksPort) {
825
+ // V2Ray: native fetch goes direct (this is correct — it proves split tunnel)
826
+ const res = await fetch(IP_CHECK_URL, { signal: AbortSignal.timeout(IP_CHECK_TIMEOUT) });
827
+ const data = await res.json();
828
+ directIp = data?.ip || null;
829
+ }
830
+ } catch { /* non-critical */ }
831
+
832
+ // Get proxy IP (through VPN)
833
+ const proxyIp = await checkVpnIp(socksPort);
834
+
835
+ // Split tunnel works when proxy and direct show different IPs
836
+ const splitTunnel = !!(proxyIp && directIp && proxyIp !== directIp);
837
+
838
+ return { splitTunnel, proxyIp, directIp, protocol };
839
+ }
840
+
841
+ // ─── onEvent() ──────────────────────────────────────────────────────────────
842
+
843
+ /**
844
+ * Subscribe to VPN connection events (progress, errors, reconnect).
845
+ *
846
+ * Event types:
847
+ * 'progress' — { step, detail } during connection
848
+ * 'connected' — connection established
849
+ * 'disconnected' — connection closed
850
+ * 'error' — { code, message } on failure
851
+ * 'reconnecting' — auto-reconnect in progress
852
+ *
853
+ * @param {function} callback - (eventType: string, data: object) => void
854
+ * @returns {function} unsubscribe — call to stop listening
855
+ */
856
+ export function onEvent(callback) {
857
+ if (!events || typeof events.on !== 'function') {
858
+ // SDK events not available — return no-op unsubscribe
859
+ return () => {};
860
+ }
861
+
862
+ // Subscribe to all relevant events — store exact handler refs for clean unsubscribe
863
+ const eventNames = [
864
+ 'progress', 'connected', 'disconnected', 'error',
865
+ 'reconnecting', 'reconnected', 'sessionEnd', 'sessionEndFailed',
866
+ ];
867
+
868
+ const handlers = new Map();
869
+ for (const name of eventNames) {
870
+ const h = (data) => {
871
+ try { callback(name, data); } catch { /* don't crash SDK */ }
872
+ };
873
+ handlers.set(name, h);
874
+ events.on(name, h);
875
+ }
876
+
877
+ // Return unsubscribe function — removes exact handler references
878
+ return () => {
879
+ for (const [name, h] of handlers) {
880
+ events.removeListener(name, h);
881
+ }
882
+ handlers.clear();
883
+ };
884
+ }