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.
- package/CHANGELOG.md +446 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/ai-path/ADMIN-ELEVATION.md +116 -0
- package/ai-path/AI-MANIFESTO.md +185 -0
- package/ai-path/BREAKING.md +74 -0
- package/ai-path/CHECKLIST.md +619 -0
- package/ai-path/CONNECTION-STEPS.md +724 -0
- package/ai-path/DECISION-TREE.md +378 -0
- package/ai-path/DEPENDENCIES.md +459 -0
- package/ai-path/E2E-FLOW.md +1555 -0
- package/ai-path/FAILURES.md +403 -0
- package/ai-path/GUIDE.md +1217 -0
- package/ai-path/README.md +558 -0
- package/ai-path/SPLIT-TUNNEL.md +266 -0
- package/ai-path/cli.js +535 -0
- package/ai-path/connect.js +884 -0
- package/ai-path/discover.js +178 -0
- package/ai-path/environment.js +266 -0
- package/ai-path/errors.js +86 -0
- package/ai-path/examples/autonomous-agent.mjs +220 -0
- package/ai-path/examples/multi-region.mjs +174 -0
- package/ai-path/examples/one-shot.mjs +31 -0
- package/ai-path/index.js +60 -0
- package/ai-path/pricing.js +136 -0
- package/ai-path/recommend.js +413 -0
- package/ai-path/run-admin.vbs +25 -0
- package/ai-path/setup.js +291 -0
- package/ai-path/wallet.js +137 -0
- package/app-helpers.js +363 -0
- package/app-settings.js +95 -0
- package/app-types.js +267 -0
- package/audit.js +847 -0
- package/batch.js +293 -0
- package/bin/setup.js +376 -0
- package/chain/authz.js +109 -0
- package/chain/broadcast.js +472 -0
- package/chain/client.js +160 -0
- package/chain/fee-grants.js +305 -0
- package/chain/index.js +891 -0
- package/chain/lcd.js +313 -0
- package/chain/queries.js +547 -0
- package/chain/rpc.js +408 -0
- package/chain/wallet.js +141 -0
- package/cli/config.js +143 -0
- package/cli/index.js +463 -0
- package/cli/output.js +182 -0
- package/cli.js +491 -0
- package/client/index.js +251 -0
- package/client.js +271 -0
- package/config/index.js +255 -0
- package/connection/connect.js +849 -0
- package/connection/disconnect.js +180 -0
- package/connection/discovery.js +321 -0
- package/connection/index.js +76 -0
- package/connection/proxy.js +148 -0
- package/connection/resilience.js +428 -0
- package/connection/security.js +232 -0
- package/connection/state.js +369 -0
- package/connection/tunnel.js +691 -0
- package/consumer.js +132 -0
- package/cosmjs-setup.js +1884 -0
- package/defaults.js +366 -0
- package/disk-cache.js +107 -0
- package/dist/client.d.ts +108 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +400 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/errors/index.js +112 -0
- package/errors.js +218 -0
- package/examples/README.md +64 -0
- package/examples/connect-direct.mjs +106 -0
- package/examples/connect-plan.mjs +125 -0
- package/examples/error-handling.mjs +109 -0
- package/examples/query-nodes.mjs +94 -0
- package/examples/wallet-basics.mjs +61 -0
- package/generated/amino/amino.ts +9 -0
- package/generated/cosmos/base/v1beta1/coin.ts +365 -0
- package/generated/cosmos_proto/cosmos.ts +323 -0
- package/generated/gogoproto/gogo.ts +9 -0
- package/generated/google/protobuf/descriptor.ts +7601 -0
- package/generated/google/protobuf/duration.ts +208 -0
- package/generated/google/protobuf/timestamp.ts +238 -0
- package/generated/sentinel/lease/v1/events.ts +924 -0
- package/generated/sentinel/lease/v1/lease.ts +292 -0
- package/generated/sentinel/lease/v1/msg.ts +949 -0
- package/generated/sentinel/lease/v1/params.ts +164 -0
- package/generated/sentinel/node/v3/events.ts +881 -0
- package/generated/sentinel/node/v3/msg.ts +1002 -0
- package/generated/sentinel/node/v3/node.ts +263 -0
- package/generated/sentinel/node/v3/params.ts +183 -0
- package/generated/sentinel/plan/v3/events.ts +675 -0
- package/generated/sentinel/plan/v3/msg.ts +1191 -0
- package/generated/sentinel/plan/v3/plan.ts +283 -0
- package/generated/sentinel/provider/v2/events.ts +171 -0
- package/generated/sentinel/provider/v2/msg.ts +480 -0
- package/generated/sentinel/provider/v2/params.ts +131 -0
- package/generated/sentinel/provider/v2/provider.ts +246 -0
- package/generated/sentinel/session/v3/events.ts +480 -0
- package/generated/sentinel/session/v3/msg.ts +616 -0
- package/generated/sentinel/session/v3/params.ts +260 -0
- package/generated/sentinel/session/v3/proof.ts +180 -0
- package/generated/sentinel/session/v3/session.ts +384 -0
- package/generated/sentinel/subscription/v3/events.ts +1181 -0
- package/generated/sentinel/subscription/v3/msg.ts +1305 -0
- package/generated/sentinel/subscription/v3/params.ts +167 -0
- package/generated/sentinel/subscription/v3/subscription.ts +315 -0
- package/generated/sentinel/types/v1/bandwidth.ts +124 -0
- package/generated/sentinel/types/v1/price.ts +149 -0
- package/generated/sentinel/types/v1/renewal.ts +87 -0
- package/generated/sentinel/types/v1/status.ts +54 -0
- package/generated/typeRegistry.ts +27 -0
- package/index.js +486 -0
- package/node-connect.js +3015 -0
- package/operator.js +134 -0
- package/package.json +113 -0
- package/plan-operations.js +199 -0
- package/preflight.js +352 -0
- package/pricing/index.js +262 -0
- package/proto/amino/amino.proto +84 -0
- package/proto/cosmos/base/v1beta1/coin.proto +61 -0
- package/proto/cosmos_proto/cosmos.proto +112 -0
- package/proto/gogoproto/gogo.proto +145 -0
- package/proto/google/api/annotations.proto +31 -0
- package/proto/google/api/http.proto +370 -0
- package/proto/google/protobuf/any.proto +106 -0
- package/proto/google/protobuf/duration.proto +115 -0
- package/proto/google/protobuf/timestamp.proto +145 -0
- package/proto/sentinel/lease/v1/events.proto +52 -0
- package/proto/sentinel/lease/v1/genesis.proto +15 -0
- package/proto/sentinel/lease/v1/lease.proto +25 -0
- package/proto/sentinel/lease/v1/msg.proto +62 -0
- package/proto/sentinel/lease/v1/params.proto +17 -0
- package/proto/sentinel/node/v3/events.proto +50 -0
- package/proto/sentinel/node/v3/genesis.proto +15 -0
- package/proto/sentinel/node/v3/msg.proto +63 -0
- package/proto/sentinel/node/v3/node.proto +27 -0
- package/proto/sentinel/node/v3/params.proto +21 -0
- package/proto/sentinel/node/v3/querier.proto +63 -0
- package/proto/sentinel/plan/v3/events.proto +41 -0
- package/proto/sentinel/plan/v3/genesis.proto +21 -0
- package/proto/sentinel/plan/v3/msg.proto +83 -0
- package/proto/sentinel/plan/v3/plan.proto +32 -0
- package/proto/sentinel/plan/v3/querier.proto +53 -0
- package/proto/sentinel/provider/v2/events.proto +16 -0
- package/proto/sentinel/provider/v2/genesis.proto +15 -0
- package/proto/sentinel/provider/v2/msg.proto +35 -0
- package/proto/sentinel/provider/v2/params.proto +17 -0
- package/proto/sentinel/provider/v2/provider.proto +24 -0
- package/proto/sentinel/provider/v3/genesis.proto +15 -0
- package/proto/sentinel/provider/v3/params.proto +13 -0
- package/proto/sentinel/session/v3/events.proto +30 -0
- package/proto/sentinel/session/v3/genesis.proto +15 -0
- package/proto/sentinel/session/v3/msg.proto +50 -0
- package/proto/sentinel/session/v3/params.proto +25 -0
- package/proto/sentinel/session/v3/proof.proto +25 -0
- package/proto/sentinel/session/v3/querier.proto +100 -0
- package/proto/sentinel/session/v3/session.proto +50 -0
- package/proto/sentinel/subscription/v2/allocation.proto +21 -0
- package/proto/sentinel/subscription/v2/payout.proto +22 -0
- package/proto/sentinel/subscription/v3/events.proto +65 -0
- package/proto/sentinel/subscription/v3/genesis.proto +17 -0
- package/proto/sentinel/subscription/v3/msg.proto +83 -0
- package/proto/sentinel/subscription/v3/params.proto +21 -0
- package/proto/sentinel/subscription/v3/subscription.proto +33 -0
- package/proto/sentinel/types/v1/bandwidth.proto +19 -0
- package/proto/sentinel/types/v1/price.proto +21 -0
- package/proto/sentinel/types/v1/renewal.proto +21 -0
- package/proto/sentinel/types/v1/status.proto +16 -0
- package/protocol/encoding.js +341 -0
- package/protocol/events.js +361 -0
- package/protocol/handshake.js +297 -0
- package/protocol/index.js +15 -0
- package/protocol/messages.js +346 -0
- package/protocol/plans.js +199 -0
- package/protocol/v2ray.js +268 -0
- package/protocol/v3.js +723 -0
- package/protocol/wireguard.js +125 -0
- package/security/index.js +132 -0
- package/session-manager.js +329 -0
- package/session-tracker.js +80 -0
- package/setup.js +376 -0
- package/speedtest/index.js +528 -0
- package/speedtest.js +567 -0
- package/src/client.ts +502 -0
- package/src/index.ts +20 -0
- package/state/index.js +347 -0
- package/state.js +516 -0
- package/test-all-chain-ops.js +493 -0
- package/test-all-logic.js +199 -0
- package/test-all-msg-types.js +292 -0
- package/test-every-connection.js +208 -0
- package/test-feegrant-connect.js +98 -0
- package/test-logic.js +148 -0
- package/test-mainnet.js +176 -0
- package/test-plan-lifecycle.js +335 -0
- package/tls-trust.js +132 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +34 -0
- package/types/chain.d.ts +746 -0
- package/types/connection.d.ts +425 -0
- package/types/errors.d.ts +174 -0
- package/types/index.d.ts +1380 -0
- package/types/nodes.d.ts +187 -0
- package/types/pricing.d.ts +156 -0
- package/types/protocol.d.ts +332 -0
- package/types/session.d.ts +236 -0
- package/types/settings.d.ts +192 -0
- package/v3protocol.js +1053 -0
- package/wallet/index.js +153 -0
- 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
|
+
}
|