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
package/audit.js
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel dVPN SDK -- Network Audit & Node Testing
|
|
3
|
+
*
|
|
4
|
+
* Utility/operator functions for testing individual nodes and auditing the
|
|
5
|
+
* network. These use the SDK's own consumer path (connectDirect/disconnect)
|
|
6
|
+
* internally -- they do NOT reimplement handshake, tunnel, or payment logic.
|
|
7
|
+
*
|
|
8
|
+
* WARNING: OPERATOR TOOL -- NOT FOR CONSUMER APPS
|
|
9
|
+
* These functions start sessions across many nodes, costing real P2P tokens.
|
|
10
|
+
* Consumer apps should use connectAuto() or connectDirect() instead.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { testNode, auditNetwork } from './audit.js';
|
|
14
|
+
*
|
|
15
|
+
* // Test a single node
|
|
16
|
+
* const result = await testNode({ mnemonic, nodeAddress: 'sentnode1...' });
|
|
17
|
+
*
|
|
18
|
+
* // Audit the whole network
|
|
19
|
+
* const { results, stats } = await auditNetwork({
|
|
20
|
+
* mnemonic, concurrency: 30, onProgress: (r) => console.log(r),
|
|
21
|
+
* });
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import os from 'os';
|
|
27
|
+
import axios from 'axios';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
connectDirect,
|
|
31
|
+
disconnect,
|
|
32
|
+
queryOnlineNodes,
|
|
33
|
+
fetchAllNodes,
|
|
34
|
+
registerCleanupHandlers,
|
|
35
|
+
events,
|
|
36
|
+
ConnectionState,
|
|
37
|
+
disconnectState,
|
|
38
|
+
} from './node-connect.js';
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
speedtestDirect,
|
|
42
|
+
speedtestViaSocks5,
|
|
43
|
+
} from './speedtest.js';
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
createWallet,
|
|
47
|
+
getBalance,
|
|
48
|
+
createClient,
|
|
49
|
+
} from './cosmjs-setup.js';
|
|
50
|
+
|
|
51
|
+
import { nodeStatusV3 } from './v3protocol.js';
|
|
52
|
+
|
|
53
|
+
import {
|
|
54
|
+
DEFAULT_RPC,
|
|
55
|
+
DEFAULT_LCD,
|
|
56
|
+
RPC_ENDPOINTS,
|
|
57
|
+
LCD_ENDPOINTS,
|
|
58
|
+
BROKEN_NODES,
|
|
59
|
+
tryWithFallback,
|
|
60
|
+
sleep,
|
|
61
|
+
} from './defaults.js';
|
|
62
|
+
|
|
63
|
+
import {
|
|
64
|
+
SentinelError,
|
|
65
|
+
ValidationError,
|
|
66
|
+
NodeError,
|
|
67
|
+
ChainError,
|
|
68
|
+
TunnelError,
|
|
69
|
+
ErrorCodes,
|
|
70
|
+
isRetryable,
|
|
71
|
+
} from './errors.js';
|
|
72
|
+
|
|
73
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const TRANSPORT_CACHE_DIR = path.join(os.homedir(), '.sentinel-sdk');
|
|
76
|
+
const TRANSPORT_CACHE_FILE = path.join(TRANSPORT_CACHE_DIR, 'transport-cache.json');
|
|
77
|
+
const TRANSPORT_CACHE_TTL = 14 * 24 * 60 * 60_000; // 14 days
|
|
78
|
+
|
|
79
|
+
const GOOGLE_CHECK_TARGETS = [
|
|
80
|
+
'https://www.google.com',
|
|
81
|
+
'https://www.google.com/generate_204',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const GOOGLE_CHECK_TIMEOUT = 10_000;
|
|
85
|
+
|
|
86
|
+
// ─── Transport Cache ──────────────────────────────────────────────────────────
|
|
87
|
+
//
|
|
88
|
+
// Learns which V2Ray transports work per node. Persists to disk at
|
|
89
|
+
// ~/.sentinel-sdk/transport-cache.json with TTL eviction.
|
|
90
|
+
//
|
|
91
|
+
// Structure:
|
|
92
|
+
// {
|
|
93
|
+
// perNode: { "sentnode1...": { protocol, network, security, port, successCount, failCount, lastSeen } },
|
|
94
|
+
// global: { "grpc/none": { success, fail, updatedAt }, ... },
|
|
95
|
+
// }
|
|
96
|
+
|
|
97
|
+
/** @type {{ perNode: Record<string, object>, global: Record<string, object> } | null} */
|
|
98
|
+
let _transportCache = null;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Load the transport cache from disk. Creates an empty cache if none exists.
|
|
102
|
+
* Evicts entries older than TRANSPORT_CACHE_TTL.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} [cachePath] - Custom path (default: ~/.sentinel-sdk/transport-cache.json)
|
|
105
|
+
* @returns {{ perNode: Record<string, object>, global: Record<string, object> }}
|
|
106
|
+
*/
|
|
107
|
+
export function loadTransportCache(cachePath) {
|
|
108
|
+
const filePath = cachePath || TRANSPORT_CACHE_FILE;
|
|
109
|
+
try {
|
|
110
|
+
if (existsSync(filePath)) {
|
|
111
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const perNode = {};
|
|
114
|
+
const global = {};
|
|
115
|
+
|
|
116
|
+
// Evict stale per-node entries
|
|
117
|
+
for (const [addr, entry] of Object.entries(raw.perNode || {})) {
|
|
118
|
+
if (entry.lastSeen && now - entry.lastSeen < TRANSPORT_CACHE_TTL) {
|
|
119
|
+
perNode[addr] = entry;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Evict stale global entries
|
|
124
|
+
for (const [key, entry] of Object.entries(raw.global || {})) {
|
|
125
|
+
if (entry.updatedAt && now - entry.updatedAt < TRANSPORT_CACHE_TTL) {
|
|
126
|
+
global[key] = entry;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_transportCache = { perNode, global };
|
|
131
|
+
} else {
|
|
132
|
+
_transportCache = { perNode: {}, global: {} };
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
_transportCache = { perNode: {}, global: {} };
|
|
136
|
+
}
|
|
137
|
+
return _transportCache;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Save the current transport cache to disk.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} [cachePath] - Custom path (default: ~/.sentinel-sdk/transport-cache.json)
|
|
144
|
+
*/
|
|
145
|
+
export function saveTransportCache(cachePath) {
|
|
146
|
+
if (!_transportCache) return;
|
|
147
|
+
const filePath = cachePath || TRANSPORT_CACHE_FILE;
|
|
148
|
+
try {
|
|
149
|
+
const dir = path.dirname(filePath);
|
|
150
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
151
|
+
writeFileSync(filePath, JSON.stringify(_transportCache, null, 2), { mode: 0o600 });
|
|
152
|
+
} catch {
|
|
153
|
+
// Disk write failed -- cache stays in memory only
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Ensure cache is loaded (lazy init). */
|
|
158
|
+
function _ensureCache() {
|
|
159
|
+
if (!_transportCache) loadTransportCache();
|
|
160
|
+
return _transportCache;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Record a successful transport for a node. Updates both per-node and global stats.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} nodeAddr - Node address (sentnode1...)
|
|
167
|
+
* @param {{ protocol: string, network: string, security: string, port: number }} transport - Working transport details
|
|
168
|
+
*/
|
|
169
|
+
export function recordTransportSuccess(nodeAddr, transport) {
|
|
170
|
+
const cache = _ensureCache();
|
|
171
|
+
const { protocol, network, security, port } = transport;
|
|
172
|
+
const globalKey = security && security !== 'none' ? `${network}/${security}` : network;
|
|
173
|
+
|
|
174
|
+
// Per-node: store the working transport
|
|
175
|
+
cache.perNode[nodeAddr] = {
|
|
176
|
+
protocol,
|
|
177
|
+
network,
|
|
178
|
+
security: security || 'none',
|
|
179
|
+
port,
|
|
180
|
+
successCount: (cache.perNode[nodeAddr]?.successCount || 0) + 1,
|
|
181
|
+
failCount: cache.perNode[nodeAddr]?.failCount || 0,
|
|
182
|
+
lastSeen: Date.now(),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Global: increment success
|
|
186
|
+
if (!cache.global[globalKey]) cache.global[globalKey] = { success: 0, fail: 0, updatedAt: 0 };
|
|
187
|
+
cache.global[globalKey].success++;
|
|
188
|
+
cache.global[globalKey].updatedAt = Date.now();
|
|
189
|
+
|
|
190
|
+
saveTransportCache();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Record a transport failure. Updates global stats only (per-node keeps last success).
|
|
195
|
+
*
|
|
196
|
+
* @param {{ protocol: string, network: string, security: string }} transport - Failed transport details
|
|
197
|
+
*/
|
|
198
|
+
export function recordTransportFailure(transport) {
|
|
199
|
+
const cache = _ensureCache();
|
|
200
|
+
const { network, security } = transport;
|
|
201
|
+
const globalKey = security && security !== 'none' ? `${network}/${security}` : network;
|
|
202
|
+
|
|
203
|
+
if (!cache.global[globalKey]) cache.global[globalKey] = { success: 0, fail: 0, updatedAt: 0 };
|
|
204
|
+
cache.global[globalKey].fail++;
|
|
205
|
+
cache.global[globalKey].updatedAt = Date.now();
|
|
206
|
+
|
|
207
|
+
saveTransportCache();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Reorder V2Ray outbounds based on cached intelligence.
|
|
212
|
+
* Cached per-node hit goes first, then sorted by global success rate (descending).
|
|
213
|
+
*
|
|
214
|
+
* @param {string} nodeAddr - Node address
|
|
215
|
+
* @param {Array<object>} outbounds - V2Ray outbound configs (from buildV2RayClientConfig)
|
|
216
|
+
* @returns {Array<object>} Reordered outbounds (new array, original unchanged)
|
|
217
|
+
*/
|
|
218
|
+
export function reorderOutbounds(nodeAddr, outbounds) {
|
|
219
|
+
const cache = _ensureCache();
|
|
220
|
+
const nodeEntry = cache.perNode[nodeAddr];
|
|
221
|
+
|
|
222
|
+
// Extract transport key from outbound
|
|
223
|
+
function outboundKey(ob) {
|
|
224
|
+
const network = ob.streamSettings?.network;
|
|
225
|
+
const security = ob.streamSettings?.security || 'none';
|
|
226
|
+
if (!network) return null;
|
|
227
|
+
return security !== 'none' ? `${network}/${security}` : network;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Get global success rate for a transport key
|
|
231
|
+
function globalRate(key) {
|
|
232
|
+
if (!key) return 0;
|
|
233
|
+
const entry = cache.global[key];
|
|
234
|
+
if (!entry) return 0.5; // unknown -- neutral
|
|
235
|
+
const total = entry.success + entry.fail;
|
|
236
|
+
if (total < 2) return 0.5;
|
|
237
|
+
return entry.success / total;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sorted = [...outbounds];
|
|
241
|
+
|
|
242
|
+
sorted.sort((a, b) => {
|
|
243
|
+
// Cached per-node hit gets priority
|
|
244
|
+
if (nodeEntry) {
|
|
245
|
+
const aMatch = a.streamSettings?.network === nodeEntry.network &&
|
|
246
|
+
(a.streamSettings?.security || 'none') === nodeEntry.security;
|
|
247
|
+
const bMatch = b.streamSettings?.network === nodeEntry.network &&
|
|
248
|
+
(b.streamSettings?.security || 'none') === nodeEntry.security;
|
|
249
|
+
if (aMatch && !bMatch) return -1;
|
|
250
|
+
if (!aMatch && bMatch) return 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Then sort by global success rate
|
|
254
|
+
const aRate = globalRate(outboundKey(a));
|
|
255
|
+
const bRate = globalRate(outboundKey(b));
|
|
256
|
+
return bRate - aRate;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return sorted;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get transport cache statistics.
|
|
264
|
+
*
|
|
265
|
+
* @returns {{ nodesCached: number, transportStats: Array<{ transport: string, success: number, fail: number, rate: number }> }}
|
|
266
|
+
*/
|
|
267
|
+
export function getCacheStats() {
|
|
268
|
+
const cache = _ensureCache();
|
|
269
|
+
const transportStats = [];
|
|
270
|
+
|
|
271
|
+
for (const [key, entry] of Object.entries(cache.global)) {
|
|
272
|
+
const total = entry.success + entry.fail;
|
|
273
|
+
transportStats.push({
|
|
274
|
+
transport: key,
|
|
275
|
+
success: entry.success,
|
|
276
|
+
fail: entry.fail,
|
|
277
|
+
rate: total > 0 ? parseFloat((entry.success / total).toFixed(3)) : 0,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Sort by sample size descending
|
|
282
|
+
transportStats.sort((a, b) => (b.success + b.fail) - (a.success + a.fail));
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
nodesCached: Object.keys(cache.perNode).length,
|
|
286
|
+
transportStats,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Google Accessibility Check ───────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check if Google is reachable through the active tunnel.
|
|
294
|
+
* For WireGuard: direct HTTPS (all traffic is tunneled).
|
|
295
|
+
* For V2Ray: routes through SOCKS5 proxy.
|
|
296
|
+
*
|
|
297
|
+
* @param {'wireguard'|'v2ray'} serviceType
|
|
298
|
+
* @param {number} [socksPort] - SOCKS5 port (V2Ray only)
|
|
299
|
+
* @returns {Promise<boolean>}
|
|
300
|
+
* @private
|
|
301
|
+
*/
|
|
302
|
+
async function _checkGoogleAccessible(serviceType, socksPort) {
|
|
303
|
+
for (const target of GOOGLE_CHECK_TARGETS) {
|
|
304
|
+
try {
|
|
305
|
+
if (serviceType === 'wireguard') {
|
|
306
|
+
// WireGuard: all traffic goes through tunnel
|
|
307
|
+
await axios.get(target, {
|
|
308
|
+
timeout: GOOGLE_CHECK_TIMEOUT,
|
|
309
|
+
maxRedirects: 2,
|
|
310
|
+
validateStatus: (s) => s < 500,
|
|
311
|
+
});
|
|
312
|
+
return true;
|
|
313
|
+
} else if (serviceType === 'v2ray' && socksPort) {
|
|
314
|
+
// V2Ray: route through SOCKS5
|
|
315
|
+
const { SocksProxyAgent } = await import('socks-proxy-agent');
|
|
316
|
+
const agent = new SocksProxyAgent(`socks5://127.0.0.1:${socksPort}`);
|
|
317
|
+
try {
|
|
318
|
+
await axios.get(target, {
|
|
319
|
+
httpAgent: agent,
|
|
320
|
+
httpsAgent: agent,
|
|
321
|
+
timeout: GOOGLE_CHECK_TIMEOUT,
|
|
322
|
+
maxRedirects: 2,
|
|
323
|
+
validateStatus: (s) => s < 500,
|
|
324
|
+
});
|
|
325
|
+
return true;
|
|
326
|
+
} finally {
|
|
327
|
+
agent.destroy();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// Try next target
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── testNode ─────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Test a single Sentinel dVPN node using the SDK's consumer connection path.
|
|
341
|
+
*
|
|
342
|
+
* Flow:
|
|
343
|
+
* 1. connectDirect() -- real wallet, real session, real tunnel
|
|
344
|
+
* 2. Speed test (WG: speedtestDirect, V2Ray: speedtestViaSocks5)
|
|
345
|
+
* 3. Google accessibility check
|
|
346
|
+
* 4. disconnect() -- clean teardown
|
|
347
|
+
*
|
|
348
|
+
* Returns a structured result with pass/fail, speed, accessibility, and diagnostics.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} options
|
|
351
|
+
* @param {string} options.mnemonic - BIP39 wallet mnemonic (required)
|
|
352
|
+
* @param {string} options.nodeAddress - Node address sentnode1... (required)
|
|
353
|
+
* @param {string} [options.rpcUrl] - RPC endpoint URL
|
|
354
|
+
* @param {string} [options.lcdUrl] - LCD endpoint URL
|
|
355
|
+
* @param {string} [options.v2rayExePath] - Path to v2ray binary (required for V2Ray nodes)
|
|
356
|
+
* @param {number} [options.gigabytes=1] - GB to allocate for session
|
|
357
|
+
* @param {number} [options.testMb=5] - Download size for speed test (MB)
|
|
358
|
+
* @param {number} [options.baselineMbps=null] - Baseline speed for scoring
|
|
359
|
+
* @param {function} [options.onLog=null] - Log callback (msg) => {}
|
|
360
|
+
* @param {function} [options.onProgress=null] - Progress callback (step, detail) => {}
|
|
361
|
+
* @param {AbortSignal} [options.signal=null] - AbortSignal for cancellation
|
|
362
|
+
* @returns {Promise<{
|
|
363
|
+
* pass: boolean,
|
|
364
|
+
* address: string,
|
|
365
|
+
* type: string,
|
|
366
|
+
* moniker: string,
|
|
367
|
+
* country: string,
|
|
368
|
+
* city: string,
|
|
369
|
+
* actualMbps: number,
|
|
370
|
+
* googleAccessible: boolean,
|
|
371
|
+
* diag: string,
|
|
372
|
+
* timestamp: string,
|
|
373
|
+
* }>}
|
|
374
|
+
*/
|
|
375
|
+
export async function testNode(options) {
|
|
376
|
+
// ── Validate inputs ──
|
|
377
|
+
if (!options || typeof options !== 'object') {
|
|
378
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'testNode() requires an options object');
|
|
379
|
+
}
|
|
380
|
+
if (typeof options.mnemonic !== 'string' || options.mnemonic.trim().split(/\s+/).length < 12) {
|
|
381
|
+
throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'mnemonic must be a 12+ word BIP39 string');
|
|
382
|
+
}
|
|
383
|
+
if (!options.nodeAddress || !options.nodeAddress.startsWith('sentnode')) {
|
|
384
|
+
throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... address');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const {
|
|
388
|
+
mnemonic,
|
|
389
|
+
nodeAddress,
|
|
390
|
+
rpcUrl,
|
|
391
|
+
lcdUrl,
|
|
392
|
+
v2rayExePath,
|
|
393
|
+
gigabytes = 1,
|
|
394
|
+
testMb = 5,
|
|
395
|
+
baselineMbps = null,
|
|
396
|
+
onLog = null,
|
|
397
|
+
onProgress = null,
|
|
398
|
+
signal = null,
|
|
399
|
+
} = options;
|
|
400
|
+
|
|
401
|
+
// Ensure cleanup handlers are registered (connectDirect requires it)
|
|
402
|
+
registerCleanupHandlers();
|
|
403
|
+
|
|
404
|
+
const log = (msg) => { if (onLog) onLog(msg); };
|
|
405
|
+
const progress = (step, detail) => { if (onProgress) onProgress(step, detail); };
|
|
406
|
+
|
|
407
|
+
const timestamp = new Date().toISOString();
|
|
408
|
+
let connResult = null;
|
|
409
|
+
let serviceType = null;
|
|
410
|
+
let moniker = '';
|
|
411
|
+
let country = '';
|
|
412
|
+
let city = '';
|
|
413
|
+
let actualMbps = 0;
|
|
414
|
+
let googleAccessible = false;
|
|
415
|
+
let diag = '';
|
|
416
|
+
let pass = false;
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
// ── Step 1: Connect via the SDK's consumer path ──
|
|
420
|
+
progress('connect', `Connecting to ${nodeAddress}...`);
|
|
421
|
+
log(`[testNode] Connecting to ${nodeAddress}`);
|
|
422
|
+
|
|
423
|
+
connResult = await connectDirect({
|
|
424
|
+
mnemonic,
|
|
425
|
+
nodeAddress,
|
|
426
|
+
rpcUrl,
|
|
427
|
+
lcdUrl,
|
|
428
|
+
v2rayExePath,
|
|
429
|
+
gigabytes,
|
|
430
|
+
fullTunnel: serviceType === 'wireguard', // WG: full tunnel for speedtest, V2Ray: SOCKS5
|
|
431
|
+
systemProxy: false, // Never set system proxy during testing
|
|
432
|
+
onProgress: (step, detail) => progress(`connect:${step}`, detail),
|
|
433
|
+
signal,
|
|
434
|
+
_skipLock: true, // Allow concurrent tests (audit mode)
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
serviceType = connResult.serviceType;
|
|
438
|
+
log(`[testNode] Connected: ${serviceType} (session ${connResult.sessionId})`);
|
|
439
|
+
|
|
440
|
+
// Extract node metadata from the connection result if available
|
|
441
|
+
if (connResult.nodeMoniker) moniker = connResult.nodeMoniker;
|
|
442
|
+
if (connResult.nodeLocation) {
|
|
443
|
+
country = connResult.nodeLocation.country || '';
|
|
444
|
+
city = connResult.nodeLocation.city || '';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// If we don't have moniker/location from connResult, fetch node status
|
|
448
|
+
if (!moniker || !country) {
|
|
449
|
+
try {
|
|
450
|
+
const lcdBase = lcdUrl || DEFAULT_LCD;
|
|
451
|
+
const { queryNode } = await import('./cosmjs-setup.js');
|
|
452
|
+
const nodeInfo = await queryNode(nodeAddress, { lcdUrl: lcdBase });
|
|
453
|
+
if (nodeInfo.remote_url) {
|
|
454
|
+
const status = await nodeStatusV3(nodeInfo.remote_url);
|
|
455
|
+
moniker = moniker || status.moniker;
|
|
456
|
+
country = country || status.location.country;
|
|
457
|
+
city = city || status.location.city;
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
// Metadata fetch failed -- non-fatal, continue with empty fields
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── Step 2: Speed test ──
|
|
465
|
+
progress('speedtest', `Running speed test (${testMb}MB)...`);
|
|
466
|
+
log(`[testNode] Running speed test (${serviceType})`);
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
let speedResult;
|
|
470
|
+
if (serviceType === 'wireguard') {
|
|
471
|
+
speedResult = await speedtestDirect();
|
|
472
|
+
} else if (serviceType === 'v2ray' && connResult.socksPort) {
|
|
473
|
+
speedResult = await speedtestViaSocks5(testMb, connResult.socksPort);
|
|
474
|
+
}
|
|
475
|
+
if (speedResult) {
|
|
476
|
+
actualMbps = speedResult.mbps || 0;
|
|
477
|
+
log(`[testNode] Speed: ${actualMbps} Mbps (${speedResult.adaptive || 'unknown'})`);
|
|
478
|
+
}
|
|
479
|
+
} catch (speedErr) {
|
|
480
|
+
diag += `speedtest_failed: ${speedErr.message}; `;
|
|
481
|
+
log(`[testNode] Speed test failed: ${speedErr.message}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Step 3: Google accessibility ──
|
|
485
|
+
progress('google', 'Checking Google accessibility...');
|
|
486
|
+
log('[testNode] Checking Google accessibility');
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
googleAccessible = await _checkGoogleAccessible(
|
|
490
|
+
serviceType,
|
|
491
|
+
connResult.socksPort,
|
|
492
|
+
);
|
|
493
|
+
log(`[testNode] Google accessible: ${googleAccessible}`);
|
|
494
|
+
} catch (googleErr) {
|
|
495
|
+
diag += `google_check_failed: ${googleErr.message}; `;
|
|
496
|
+
log(`[testNode] Google check failed: ${googleErr.message}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Determine pass/fail ──
|
|
500
|
+
// Pass: connected + has some measurable speed + Google is accessible
|
|
501
|
+
pass = actualMbps > 0 && googleAccessible;
|
|
502
|
+
if (pass) {
|
|
503
|
+
diag = diag || 'ok';
|
|
504
|
+
} else if (!googleAccessible) {
|
|
505
|
+
diag += 'google_unreachable; ';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
} catch (connectErr) {
|
|
509
|
+
// Connection failed entirely
|
|
510
|
+
diag = `connect_failed: ${connectErr.code || connectErr.name}: ${connectErr.message}`;
|
|
511
|
+
log(`[testNode] Connection failed: ${diag}`);
|
|
512
|
+
|
|
513
|
+
// Try to extract node metadata from the error or via status probe
|
|
514
|
+
if (!moniker || !country) {
|
|
515
|
+
try {
|
|
516
|
+
const lcdBase = lcdUrl || DEFAULT_LCD;
|
|
517
|
+
const { queryNode } = await import('./cosmjs-setup.js');
|
|
518
|
+
const nodeInfo = await queryNode(nodeAddress, { lcdUrl: lcdBase });
|
|
519
|
+
if (nodeInfo.remote_url) {
|
|
520
|
+
const status = await nodeStatusV3(nodeInfo.remote_url);
|
|
521
|
+
serviceType = serviceType || status.type;
|
|
522
|
+
moniker = moniker || status.moniker;
|
|
523
|
+
country = country || status.location.country;
|
|
524
|
+
city = city || status.location.city;
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
// Can't reach node at all
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
// ── Step 4: Disconnect (always) ──
|
|
532
|
+
progress('disconnect', 'Disconnecting...');
|
|
533
|
+
try {
|
|
534
|
+
if (connResult?.cleanup) {
|
|
535
|
+
await connResult.cleanup();
|
|
536
|
+
} else {
|
|
537
|
+
await disconnect();
|
|
538
|
+
}
|
|
539
|
+
log('[testNode] Disconnected');
|
|
540
|
+
} catch (dcErr) {
|
|
541
|
+
log(`[testNode] Disconnect warning: ${dcErr.message}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const result = {
|
|
546
|
+
pass,
|
|
547
|
+
address: nodeAddress,
|
|
548
|
+
type: serviceType || 'unknown',
|
|
549
|
+
moniker,
|
|
550
|
+
country,
|
|
551
|
+
city,
|
|
552
|
+
actualMbps: parseFloat(actualMbps.toFixed(2)),
|
|
553
|
+
googleAccessible,
|
|
554
|
+
diag: diag.replace(/;\s*$/, '') || 'unknown',
|
|
555
|
+
timestamp,
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// Record transport cache data for V2Ray nodes
|
|
559
|
+
if (serviceType === 'v2ray' && connResult?.outbound) {
|
|
560
|
+
try {
|
|
561
|
+
const obTag = connResult.outbound;
|
|
562
|
+
// Parse transport info from outbound tag (format: "proto-network-security-port")
|
|
563
|
+
const parts = obTag.split('-');
|
|
564
|
+
if (parts.length >= 2) {
|
|
565
|
+
const transport = {
|
|
566
|
+
protocol: parts[0] || 'vmess',
|
|
567
|
+
network: parts[1] || 'tcp',
|
|
568
|
+
security: parts[2] || 'none',
|
|
569
|
+
port: parseInt(parts[3]) || 0,
|
|
570
|
+
};
|
|
571
|
+
if (pass) {
|
|
572
|
+
recordTransportSuccess(nodeAddress, transport);
|
|
573
|
+
} else {
|
|
574
|
+
recordTransportFailure(transport);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
// Transport cache update failed -- non-fatal
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ─── auditNetwork ─────────────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Audit the Sentinel dVPN network by testing all (or some) active nodes.
|
|
589
|
+
*
|
|
590
|
+
* Flow:
|
|
591
|
+
* 1. Wallet setup + balance check
|
|
592
|
+
* 2. Fetch all active nodes from LCD
|
|
593
|
+
* 3. Parallel online scan (probe each node's status endpoint)
|
|
594
|
+
* 4. For each viable node: testNode() with retry
|
|
595
|
+
* 5. Emit progress events, return results + stats
|
|
596
|
+
*
|
|
597
|
+
* @param {object} options
|
|
598
|
+
* @param {string} options.mnemonic - BIP39 wallet mnemonic (required)
|
|
599
|
+
* @param {string} [options.rpcUrl] - RPC endpoint URL
|
|
600
|
+
* @param {string} [options.lcdUrl] - LCD endpoint URL
|
|
601
|
+
* @param {string} [options.v2rayExePath] - Path to v2ray binary
|
|
602
|
+
* @param {number} [options.concurrency=30] - Parallel status scan concurrency
|
|
603
|
+
* @param {number} [options.batchSize=5] - Payment batching (for display only; testNode pays individually)
|
|
604
|
+
* @param {number} [options.gigabytesPerNode=1] - GB per session
|
|
605
|
+
* @param {number} [options.testMb=5] - Speed test download size (MB)
|
|
606
|
+
* @param {number} [options.maxNodes=0] - 0 = test all viable nodes
|
|
607
|
+
* @param {Array} [options.resume=null] - Previous results array to skip already-tested nodes
|
|
608
|
+
* @param {function} [options.onProgress=null] - Called with each test result: (result) => {}
|
|
609
|
+
* @param {function} [options.onLog=null] - Log callback: (msg) => {}
|
|
610
|
+
* @param {function} [options.onBatchPayment=null] - Called per batch: (batchNum, total) => {}
|
|
611
|
+
* @param {AbortSignal} [options.signal=null] - AbortSignal for cancellation
|
|
612
|
+
* @returns {Promise<{
|
|
613
|
+
* results: Array<object>,
|
|
614
|
+
* stats: {
|
|
615
|
+
* total: number,
|
|
616
|
+
* tested: number,
|
|
617
|
+
* passed: number,
|
|
618
|
+
* failed: number,
|
|
619
|
+
* skipped: number,
|
|
620
|
+
* avgMbps: number,
|
|
621
|
+
* googleAccessiblePct: number,
|
|
622
|
+
* durationMs: number,
|
|
623
|
+
* },
|
|
624
|
+
* }>}
|
|
625
|
+
*/
|
|
626
|
+
export async function auditNetwork(options) {
|
|
627
|
+
// ── Validate inputs ──
|
|
628
|
+
if (!options || typeof options !== 'object') {
|
|
629
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'auditNetwork() requires an options object');
|
|
630
|
+
}
|
|
631
|
+
if (typeof options.mnemonic !== 'string' || options.mnemonic.trim().split(/\s+/).length < 12) {
|
|
632
|
+
throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'mnemonic must be a 12+ word BIP39 string');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const {
|
|
636
|
+
mnemonic,
|
|
637
|
+
rpcUrl,
|
|
638
|
+
lcdUrl,
|
|
639
|
+
v2rayExePath,
|
|
640
|
+
concurrency = 30,
|
|
641
|
+
batchSize = 5,
|
|
642
|
+
gigabytesPerNode = 1,
|
|
643
|
+
testMb = 5,
|
|
644
|
+
maxNodes = 0,
|
|
645
|
+
resume = null,
|
|
646
|
+
onProgress = null,
|
|
647
|
+
onLog = null,
|
|
648
|
+
onBatchPayment = null,
|
|
649
|
+
signal = null,
|
|
650
|
+
} = options;
|
|
651
|
+
|
|
652
|
+
const log = (msg) => { if (onLog) onLog(msg); };
|
|
653
|
+
const auditStart = Date.now();
|
|
654
|
+
const results = [];
|
|
655
|
+
|
|
656
|
+
// Ensure cleanup handlers are registered (idempotent)
|
|
657
|
+
registerCleanupHandlers();
|
|
658
|
+
|
|
659
|
+
// Build skip set from resume data
|
|
660
|
+
const skipSet = new Set();
|
|
661
|
+
if (resume && Array.isArray(resume)) {
|
|
662
|
+
for (const r of resume) {
|
|
663
|
+
if (r.address) skipSet.add(r.address);
|
|
664
|
+
}
|
|
665
|
+
log(`[audit] Resuming: ${skipSet.size} nodes already tested, will skip`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ── Step 1: Wallet setup + balance check ──
|
|
669
|
+
log('[audit] Setting up wallet...');
|
|
670
|
+
_checkAborted(signal);
|
|
671
|
+
|
|
672
|
+
let walletAddress = '';
|
|
673
|
+
let balanceDvpn = 0;
|
|
674
|
+
try {
|
|
675
|
+
const { wallet, account } = await createWallet(mnemonic);
|
|
676
|
+
walletAddress = account.address;
|
|
677
|
+
|
|
678
|
+
const clientResult = rpcUrl
|
|
679
|
+
? { result: await createClient(rpcUrl, wallet) }
|
|
680
|
+
: await tryWithFallback(RPC_ENDPOINTS, async (url) => createClient(url, wallet), 'RPC connect');
|
|
681
|
+
const client = clientResult.result || clientResult;
|
|
682
|
+
const bal = await getBalance(client, walletAddress);
|
|
683
|
+
balanceDvpn = bal.dvpn;
|
|
684
|
+
log(`[audit] Wallet: ${walletAddress} | ${balanceDvpn.toFixed(1)} P2P`);
|
|
685
|
+
|
|
686
|
+
if (balanceDvpn < 1) {
|
|
687
|
+
throw new ChainError(
|
|
688
|
+
ErrorCodes.INSUFFICIENT_BALANCE,
|
|
689
|
+
`Wallet has ${balanceDvpn.toFixed(2)} P2P -- need at least 1 P2P for network audit. Fund ${walletAddress}.`,
|
|
690
|
+
{ balance: bal, address: walletAddress },
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
} catch (err) {
|
|
694
|
+
if (err.code === ErrorCodes.INSUFFICIENT_BALANCE) throw err;
|
|
695
|
+
log(`[audit] Wallet setup warning: ${err.message}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── Step 2: Fetch all active nodes ──
|
|
699
|
+
log('[audit] Fetching active nodes from chain...');
|
|
700
|
+
_checkAborted(signal);
|
|
701
|
+
|
|
702
|
+
let allNodes;
|
|
703
|
+
try {
|
|
704
|
+
allNodes = await queryOnlineNodes({
|
|
705
|
+
lcdUrl,
|
|
706
|
+
maxNodes: maxNodes > 0 ? maxNodes * 3 : 5000, // Fetch extra for filtering
|
|
707
|
+
concurrency,
|
|
708
|
+
noCache: true,
|
|
709
|
+
sort: true,
|
|
710
|
+
});
|
|
711
|
+
log(`[audit] Found ${allNodes.length} online nodes`);
|
|
712
|
+
} catch (err) {
|
|
713
|
+
throw new ChainError(
|
|
714
|
+
ErrorCodes.LCD_ERROR,
|
|
715
|
+
`Failed to fetch nodes: ${err.message}`,
|
|
716
|
+
{ original: err.message },
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── Step 3: Filter and prepare test queue ──
|
|
721
|
+
const brokenAddrs = new Set(BROKEN_NODES.map(n => n.address));
|
|
722
|
+
let viableNodes = allNodes.filter(n =>
|
|
723
|
+
!brokenAddrs.has(n.address) &&
|
|
724
|
+
!skipSet.has(n.address),
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
if (maxNodes > 0 && viableNodes.length > maxNodes) {
|
|
728
|
+
viableNodes = viableNodes.slice(0, maxNodes);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const totalToTest = viableNodes.length;
|
|
732
|
+
log(`[audit] Testing ${totalToTest} nodes (${skipSet.size} skipped from resume, ${brokenAddrs.size} known broken)`);
|
|
733
|
+
|
|
734
|
+
// ── Step 4: Test each node sequentially ──
|
|
735
|
+
// Sequential testing is required because:
|
|
736
|
+
// - connectDirect uses system-level tunnels (WireGuard) that conflict when parallel
|
|
737
|
+
// - Each test creates/tears down a tunnel -- cannot have multiple active tunnels
|
|
738
|
+
let testedCount = 0;
|
|
739
|
+
let passedCount = 0;
|
|
740
|
+
let failedCount = 0;
|
|
741
|
+
let totalMbps = 0;
|
|
742
|
+
let googleCount = 0;
|
|
743
|
+
|
|
744
|
+
for (let i = 0; i < viableNodes.length; i++) {
|
|
745
|
+
_checkAborted(signal);
|
|
746
|
+
|
|
747
|
+
const node = viableNodes[i];
|
|
748
|
+
const batchNum = Math.floor(i / batchSize) + 1;
|
|
749
|
+
const totalBatches = Math.ceil(viableNodes.length / batchSize);
|
|
750
|
+
|
|
751
|
+
if (i % batchSize === 0 && onBatchPayment) {
|
|
752
|
+
onBatchPayment(batchNum, totalBatches);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
log(`[audit] [${i + 1}/${totalToTest}] Testing ${node.address} (${node.moniker || 'unknown'}, ${node.serviceType || 'unknown'})...`);
|
|
756
|
+
|
|
757
|
+
let result;
|
|
758
|
+
try {
|
|
759
|
+
result = await testNode({
|
|
760
|
+
mnemonic,
|
|
761
|
+
nodeAddress: node.address,
|
|
762
|
+
rpcUrl,
|
|
763
|
+
lcdUrl,
|
|
764
|
+
v2rayExePath,
|
|
765
|
+
gigabytes: gigabytesPerNode,
|
|
766
|
+
testMb,
|
|
767
|
+
onLog,
|
|
768
|
+
signal,
|
|
769
|
+
});
|
|
770
|
+
} catch (err) {
|
|
771
|
+
// testNode should not throw (it catches internally), but handle just in case
|
|
772
|
+
result = {
|
|
773
|
+
pass: false,
|
|
774
|
+
address: node.address,
|
|
775
|
+
type: node.serviceType || 'unknown',
|
|
776
|
+
moniker: node.moniker || '',
|
|
777
|
+
country: node.country || '',
|
|
778
|
+
city: node.city || '',
|
|
779
|
+
actualMbps: 0,
|
|
780
|
+
googleAccessible: false,
|
|
781
|
+
diag: `unexpected_error: ${err.message}`,
|
|
782
|
+
timestamp: new Date().toISOString(),
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Merge node metadata from scan if missing from result
|
|
787
|
+
if (!result.moniker && node.moniker) result.moniker = node.moniker;
|
|
788
|
+
if (!result.country && node.country) result.country = node.country;
|
|
789
|
+
if (!result.city && node.city) result.city = node.city;
|
|
790
|
+
if (result.type === 'unknown' && node.serviceType) result.type = node.serviceType;
|
|
791
|
+
|
|
792
|
+
results.push(result);
|
|
793
|
+
testedCount++;
|
|
794
|
+
|
|
795
|
+
if (result.pass) {
|
|
796
|
+
passedCount++;
|
|
797
|
+
totalMbps += result.actualMbps;
|
|
798
|
+
} else {
|
|
799
|
+
failedCount++;
|
|
800
|
+
}
|
|
801
|
+
if (result.googleAccessible) googleCount++;
|
|
802
|
+
|
|
803
|
+
// Emit progress
|
|
804
|
+
if (onProgress) {
|
|
805
|
+
try {
|
|
806
|
+
onProgress(result);
|
|
807
|
+
} catch {
|
|
808
|
+
// Progress callback error -- non-fatal
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
log(`[audit] [${i + 1}/${totalToTest}] ${result.pass ? 'PASS' : 'FAIL'} ${node.address} | ${result.actualMbps} Mbps | Google: ${result.googleAccessible}`);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ── Step 5: Compute stats ──
|
|
816
|
+
const durationMs = Date.now() - auditStart;
|
|
817
|
+
const stats = {
|
|
818
|
+
total: totalToTest + skipSet.size,
|
|
819
|
+
tested: testedCount,
|
|
820
|
+
passed: passedCount,
|
|
821
|
+
failed: failedCount,
|
|
822
|
+
skipped: skipSet.size,
|
|
823
|
+
avgMbps: passedCount > 0 ? parseFloat((totalMbps / passedCount).toFixed(2)) : 0,
|
|
824
|
+
googleAccessiblePct: testedCount > 0
|
|
825
|
+
? parseFloat(((googleCount / testedCount) * 100).toFixed(1))
|
|
826
|
+
: 0,
|
|
827
|
+
durationMs,
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
log(`[audit] Complete: ${passedCount}/${testedCount} passed, avg ${stats.avgMbps} Mbps, ${stats.googleAccessiblePct}% Google accessible, ${(durationMs / 1000 / 60).toFixed(1)} min`);
|
|
831
|
+
|
|
832
|
+
return { results, stats };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ─── Internal Helpers ─────────────────────────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Check if an AbortSignal has been triggered.
|
|
839
|
+
* @param {AbortSignal|null} signal
|
|
840
|
+
* @throws {SentinelError} if aborted
|
|
841
|
+
* @private
|
|
842
|
+
*/
|
|
843
|
+
function _checkAborted(signal) {
|
|
844
|
+
if (signal?.aborted) {
|
|
845
|
+
throw new SentinelError(ErrorCodes.ABORTED, 'Audit was cancelled');
|
|
846
|
+
}
|
|
847
|
+
}
|