blue-js-sdk 2.4.0 → 2.7.0

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