blue-js-sdk 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app-helpers.js +55 -0
- package/chain/broadcast.js +27 -0
- package/chain/fee-grants.js +77 -7
- package/chain/queries.js +72 -0
- package/chain/rpc.js +59 -2
- package/cli.js +26 -5
- package/client.js +62 -6
- package/connection/connect.js +103 -17
- package/connection/disconnect.js +9 -4
- package/connection/logger.js +66 -0
- package/connection/resilience.js +12 -7
- package/connection/state.js +21 -12
- package/connection/tunnel.js +24 -8
- package/cosmjs-setup.js +42 -0
- package/docs/PRIVY-INTEGRATION.md +177 -0
- package/errors.js +167 -0
- package/index.js +70 -1
- package/node-connect.js +92 -40
- package/operator.js +24 -0
- package/package.json +11 -8
- package/session-manager.js +68 -0
- package/speedtest.js +139 -0
- package/test-all-logic.js +8 -6
- package/test-e2e.js +138 -0
- package/test-mainnet.js +2 -2
- package/test-plan-connect-e2e.js +235 -0
- package/test-subscription-flows.js +14 -4
- package/types/connection.d.ts +6 -2
package/node-connect.js
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
SentinelError, ValidationError, NodeError, ChainError, TunnelError, ErrorCodes,
|
|
63
63
|
} from './errors.js';
|
|
64
64
|
import { createNodeHttpsAgent, publicEndpointAgent } from './tls-trust.js';
|
|
65
|
+
import { withMnemonicRedaction } from './connection/logger.js';
|
|
65
66
|
|
|
66
67
|
// CA-validated agent for LCD/RPC public endpoints (valid CA certs)
|
|
67
68
|
const httpsAgent = publicEndpointAgent;
|
|
@@ -116,7 +117,9 @@ export class ConnectionState {
|
|
|
116
117
|
this.systemProxy = false;
|
|
117
118
|
this.connection = null; // { nodeAddress, serviceType, sessionId, connectedAt, socksPort? }
|
|
118
119
|
this.savedProxyState = null;
|
|
119
|
-
|
|
120
|
+
// v37 (security): store the derived OfflineSigner — NOT the BIP-39 mnemonic.
|
|
121
|
+
// See connection/state.js for the rationale (heap-dump attack surface).
|
|
122
|
+
this._wallet = null; // OfflineSigner — used by _endSessionOnChain on disconnect
|
|
120
123
|
this._feeGranter = null; // Stored for fee-granted session end on disconnect (0-P2P agents)
|
|
121
124
|
_activeStates.add(this);
|
|
122
125
|
}
|
|
@@ -128,8 +131,13 @@ export class ConnectionState {
|
|
|
128
131
|
const _activeStates = new Set();
|
|
129
132
|
const _defaultState = new ConnectionState();
|
|
130
133
|
|
|
131
|
-
// Default logger — can be overridden per-call via opts.log
|
|
132
|
-
|
|
134
|
+
// Default logger — can be overridden per-call via opts.log.
|
|
135
|
+
// Wrapped with mnemonic redaction so any 12–24 word BIP-39 phrase that ends up
|
|
136
|
+
// in a log argument is replaced with `[REDACTED MNEMONIC]` before reaching
|
|
137
|
+
// stdout. Defense-in-depth — the SDK does not currently log the mnemonic, but
|
|
138
|
+
// a future template-string bug or careless `JSON.stringify(opts)` won't leak
|
|
139
|
+
// it through the default logger.
|
|
140
|
+
let defaultLog = withMnemonicRedaction(console.log);
|
|
133
141
|
|
|
134
142
|
// ─── Wallet Cache ────────────────────────────────────────────────────────────
|
|
135
143
|
// v21: Cache wallet derivation (BIP39 → SLIP-10 is CPU-bound, ~300ms).
|
|
@@ -798,12 +806,12 @@ export async function tryFastReconnect(opts, state = _defaultState) {
|
|
|
798
806
|
if (_killSwitchEnabled) disableKillSwitch();
|
|
799
807
|
try { await disconnectWireGuard(); } catch {}
|
|
800
808
|
// End session on chain (fire-and-forget)
|
|
801
|
-
if (saved.sessionId && state.
|
|
802
|
-
_endSessionOnChain(saved.sessionId, state.
|
|
809
|
+
if (saved.sessionId && state._wallet) {
|
|
810
|
+
_endSessionOnChain(saved.sessionId, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
803
811
|
}
|
|
804
812
|
state.wgTunnel = null;
|
|
805
813
|
state.connection = null;
|
|
806
|
-
state.
|
|
814
|
+
state._wallet = null;
|
|
807
815
|
clearState();
|
|
808
816
|
},
|
|
809
817
|
};
|
|
@@ -923,11 +931,11 @@ export async function tryFastReconnect(opts, state = _defaultState) {
|
|
|
923
931
|
if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
|
|
924
932
|
if (state.systemProxy) clearSystemProxy(state);
|
|
925
933
|
// End session on chain (fire-and-forget)
|
|
926
|
-
if (sessionIdStr && state.
|
|
927
|
-
_endSessionOnChain(sessionIdStr, state.
|
|
934
|
+
if (sessionIdStr && state._wallet) {
|
|
935
|
+
_endSessionOnChain(sessionIdStr, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
928
936
|
}
|
|
929
937
|
state.connection = null;
|
|
930
|
-
state.
|
|
938
|
+
state._wallet = null;
|
|
931
939
|
clearState();
|
|
932
940
|
},
|
|
933
941
|
};
|
|
@@ -993,9 +1001,11 @@ export async function connectDirect(opts) {
|
|
|
993
1001
|
|
|
994
1002
|
// ── Fast Reconnect: check for saved credentials ──
|
|
995
1003
|
if (!forceNewSession) {
|
|
996
|
-
//
|
|
997
|
-
|
|
998
|
-
const
|
|
1004
|
+
// v37: Derive wallet up front so _endSessionOnChain() has a signer if fast reconnect succeeds.
|
|
1005
|
+
// cachedCreateWallet is keyed on mnemonic SHA256 — connectInternal() below reuses the same object.
|
|
1006
|
+
const fastState = opts._state || _defaultState;
|
|
1007
|
+
fastState._wallet = await cachedCreateWallet(opts.mnemonic);
|
|
1008
|
+
const fast = await tryFastReconnect(opts, fastState);
|
|
999
1009
|
if (fast) {
|
|
1000
1010
|
_circuitBreaker.delete(opts.nodeAddress);
|
|
1001
1011
|
return fast;
|
|
@@ -1017,7 +1027,10 @@ export async function connectDirect(opts) {
|
|
|
1017
1027
|
// lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
|
|
1018
1028
|
onStaleDuplicate: (staleId) => {
|
|
1019
1029
|
logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
|
|
1020
|
-
|
|
1030
|
+
// v37: pass the derived wallet (set on state above) instead of the mnemonic
|
|
1031
|
+
const w = (opts._state || _defaultState)._wallet;
|
|
1032
|
+
if (!w) { logFn?.(`[connect] No wallet on state — skipping stale session ${staleId} cleanup`); return; }
|
|
1033
|
+
_endSessionOnChain(staleId, w, opts.feeGranter || null).catch(e => {
|
|
1021
1034
|
logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
|
|
1022
1035
|
});
|
|
1023
1036
|
},
|
|
@@ -1152,7 +1165,7 @@ export async function connectAuto(opts) {
|
|
|
1152
1165
|
if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker);
|
|
1153
1166
|
|
|
1154
1167
|
const maxAttempts = opts.maxAttempts || 3;
|
|
1155
|
-
const logFn = opts.log ||
|
|
1168
|
+
const logFn = opts.log || defaultLog;
|
|
1156
1169
|
const errors = [];
|
|
1157
1170
|
|
|
1158
1171
|
// If nodeAddress specified, try it first (skip circuit breaker check for explicit choice)
|
|
@@ -1443,6 +1456,38 @@ export async function connectViaSubscription(opts) {
|
|
|
1443
1456
|
|
|
1444
1457
|
// ─── Shared Validation ───────────────────────────────────────────────────────
|
|
1445
1458
|
|
|
1459
|
+
/**
|
|
1460
|
+
* Validate a chain endpoint URL. Enforces https:// for non-localhost endpoints
|
|
1461
|
+
* to prevent attackers from coercing the SDK into broadcasting signed TXs over
|
|
1462
|
+
* cleartext HTTP. Localhost (loopback) is exempted for local-node development.
|
|
1463
|
+
*
|
|
1464
|
+
* @param {*} url - Value to validate (allowed: undefined/null, or string)
|
|
1465
|
+
* @param {string} fieldName - 'rpcUrl' or 'lcdUrl', used in error messages
|
|
1466
|
+
*/
|
|
1467
|
+
function validateChainUrl(url, fieldName) {
|
|
1468
|
+
if (url == null) return;
|
|
1469
|
+
if (typeof url !== 'string') {
|
|
1470
|
+
throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must be a string URL`, { value: url });
|
|
1471
|
+
}
|
|
1472
|
+
let parsed;
|
|
1473
|
+
try { parsed = new URL(url); }
|
|
1474
|
+
catch { throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} is not a valid URL`, { value: url }); }
|
|
1475
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
1476
|
+
throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must use http:// or https:// (got ${parsed.protocol})`, { value: url });
|
|
1477
|
+
}
|
|
1478
|
+
const isLocalhost = parsed.hostname === 'localhost'
|
|
1479
|
+
|| parsed.hostname === '127.0.0.1'
|
|
1480
|
+
|| parsed.hostname === '::1'
|
|
1481
|
+
|| parsed.hostname === '[::1]';
|
|
1482
|
+
if (parsed.protocol === 'http:' && !isLocalhost) {
|
|
1483
|
+
throw new ValidationError(
|
|
1484
|
+
ErrorCodes.INVALID_URL,
|
|
1485
|
+
`${fieldName} must use https:// for non-localhost endpoints. Cleartext HTTP leaks signed TXs and queries to network observers (got ${url}).`,
|
|
1486
|
+
{ value: url },
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1446
1491
|
function validateConnectOpts(opts, fnName) {
|
|
1447
1492
|
if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `${fnName}() requires an options object`);
|
|
1448
1493
|
if (typeof opts.mnemonic !== 'string' || opts.mnemonic.trim().split(/\s+/).length < 12) {
|
|
@@ -1451,8 +1496,8 @@ function validateConnectOpts(opts, fnName) {
|
|
|
1451
1496
|
if (typeof opts.nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(opts.nodeAddress)) {
|
|
1452
1497
|
throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (47 characters)', { value: opts.nodeAddress });
|
|
1453
1498
|
}
|
|
1454
|
-
|
|
1455
|
-
|
|
1499
|
+
validateChainUrl(opts.rpcUrl, 'rpcUrl');
|
|
1500
|
+
validateChainUrl(opts.lcdUrl, 'lcdUrl');
|
|
1456
1501
|
}
|
|
1457
1502
|
|
|
1458
1503
|
// ─── Shared Connect Flow (eliminates connectDirect/connectViaPlan duplication) ─
|
|
@@ -1490,13 +1535,17 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
|
|
|
1490
1535
|
// v21: parallelized — saves ~300ms (was sequential)
|
|
1491
1536
|
progress(onProgress, logFn, 'wallet', 'Setting up wallet...');
|
|
1492
1537
|
checkAborted(signal);
|
|
1493
|
-
const [
|
|
1538
|
+
const [walletResult, privKey] = await Promise.all([
|
|
1494
1539
|
cachedCreateWallet(opts.mnemonic),
|
|
1495
1540
|
privKeyFromMnemonic(opts.mnemonic),
|
|
1496
1541
|
]);
|
|
1542
|
+
const { wallet, account } = walletResult;
|
|
1497
1543
|
|
|
1498
|
-
//
|
|
1499
|
-
|
|
1544
|
+
// v37 (security): store the derived OfflineSigner — NOT the raw BIP-39 phrase.
|
|
1545
|
+
// _endSessionOnChain only signs one TX on disconnect; it doesn't need the recovery
|
|
1546
|
+
// phrase, and keeping the phrase in heap for the full session expanded the
|
|
1547
|
+
// heap-dump attack surface unnecessarily.
|
|
1548
|
+
state._wallet = walletResult;
|
|
1500
1549
|
state._feeGranter = opts.feeGranter || null;
|
|
1501
1550
|
|
|
1502
1551
|
// 2. RPC connect + LCD lookup in parallel (independent network calls)
|
|
@@ -1884,12 +1933,12 @@ async function setupWireGuard({ remoteUrl, sessionId, privKey, fullTunnel, split
|
|
|
1884
1933
|
if (_killSwitchEnabled) disableKillSwitch();
|
|
1885
1934
|
try { await disconnectWireGuard(); } catch {} // tunnel may already be down
|
|
1886
1935
|
// End session on chain (fire-and-forget)
|
|
1887
|
-
if (sessionIdStr && state.
|
|
1888
|
-
_endSessionOnChain(sessionIdStr, state.
|
|
1936
|
+
if (sessionIdStr && state._wallet) {
|
|
1937
|
+
_endSessionOnChain(sessionIdStr, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
1889
1938
|
}
|
|
1890
1939
|
state.wgTunnel = null;
|
|
1891
1940
|
state.connection = null;
|
|
1892
|
-
state.
|
|
1941
|
+
state._wallet = null;
|
|
1893
1942
|
clearState();
|
|
1894
1943
|
},
|
|
1895
1944
|
};
|
|
@@ -2148,11 +2197,11 @@ async function setupV2Ray({ remoteUrl, serverHost, sessionId, privKey, v2rayExeP
|
|
|
2148
2197
|
if (state.v2rayProc) { state.v2rayProc.kill(); state.v2rayProc = null; await sleep(500); }
|
|
2149
2198
|
if (state.systemProxy) clearSystemProxy();
|
|
2150
2199
|
// End session on chain (fire-and-forget)
|
|
2151
|
-
if (sessionIdStr && state.
|
|
2152
|
-
_endSessionOnChain(sessionIdStr, state.
|
|
2200
|
+
if (sessionIdStr && state._wallet) {
|
|
2201
|
+
_endSessionOnChain(sessionIdStr, state._wallet, state._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
2153
2202
|
}
|
|
2154
2203
|
state.connection = null;
|
|
2155
|
-
state.
|
|
2204
|
+
state._wallet = null;
|
|
2156
2205
|
clearState();
|
|
2157
2206
|
},
|
|
2158
2207
|
};
|
|
@@ -2183,8 +2232,8 @@ export function getStatus() {
|
|
|
2183
2232
|
const stale = _defaultState.connection;
|
|
2184
2233
|
_defaultState.connection = null;
|
|
2185
2234
|
// End session on chain (fire-and-forget) to prevent stale session leaks
|
|
2186
|
-
if (stale?.sessionId && _defaultState.
|
|
2187
|
-
_endSessionOnChain(stale.sessionId, _defaultState.
|
|
2235
|
+
if (stale?.sessionId && _defaultState._wallet) {
|
|
2236
|
+
_endSessionOnChain(stale.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
2188
2237
|
}
|
|
2189
2238
|
clearState();
|
|
2190
2239
|
events.emit('disconnected', { nodeAddress: stale.nodeAddress, serviceType: stale.serviceType, reason: 'phantom_state' });
|
|
@@ -2230,8 +2279,8 @@ export function getStatus() {
|
|
|
2230
2279
|
// clean up stale state. Prevents ghost "connected" status after tunnel dies.
|
|
2231
2280
|
if (!healthChecks.tunnelActive && !_defaultState.v2rayProc && !_defaultState.wgTunnel) {
|
|
2232
2281
|
// Both tunnel handles are null — connection state is stale
|
|
2233
|
-
if (conn?.sessionId && _defaultState.
|
|
2234
|
-
_endSessionOnChain(conn.sessionId, _defaultState.
|
|
2282
|
+
if (conn?.sessionId && _defaultState._wallet) {
|
|
2283
|
+
_endSessionOnChain(conn.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
2235
2284
|
}
|
|
2236
2285
|
_defaultState.connection = null;
|
|
2237
2286
|
clearState();
|
|
@@ -2239,8 +2288,8 @@ export function getStatus() {
|
|
|
2239
2288
|
}
|
|
2240
2289
|
if (_defaultState.wgTunnel && !healthChecks.tunnelActive) {
|
|
2241
2290
|
// WireGuard state says connected but tunnel is dead — auto-cleanup
|
|
2242
|
-
if (conn?.sessionId && _defaultState.
|
|
2243
|
-
_endSessionOnChain(conn.sessionId, _defaultState.
|
|
2291
|
+
if (conn?.sessionId && _defaultState._wallet) {
|
|
2292
|
+
_endSessionOnChain(conn.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
2244
2293
|
}
|
|
2245
2294
|
_defaultState.wgTunnel = null;
|
|
2246
2295
|
_defaultState.connection = null;
|
|
@@ -2250,8 +2299,8 @@ export function getStatus() {
|
|
|
2250
2299
|
}
|
|
2251
2300
|
if (_defaultState.v2rayProc && !healthChecks.tunnelActive) {
|
|
2252
2301
|
// V2Ray process died — auto-cleanup
|
|
2253
|
-
if (conn?.sessionId && _defaultState.
|
|
2254
|
-
_endSessionOnChain(conn.sessionId, _defaultState.
|
|
2302
|
+
if (conn?.sessionId && _defaultState._wallet) {
|
|
2303
|
+
_endSessionOnChain(conn.sessionId, _defaultState._wallet, _defaultState._feeGranter).then(r => events.emit('sessionEnded', { txHash: r?.transactionHash })).catch(e => events.emit('sessionEndFailed', { error: e.message }));
|
|
2255
2304
|
}
|
|
2256
2305
|
_defaultState.v2rayProc = null;
|
|
2257
2306
|
_defaultState.connection = null;
|
|
@@ -2335,8 +2384,8 @@ async function _disconnectInternal(state, { endSession }) {
|
|
|
2335
2384
|
|
|
2336
2385
|
if (endSession) {
|
|
2337
2386
|
// Hard disconnect: broadcast MsgCancelSession — session settles and refunds deposit.
|
|
2338
|
-
if (prev?.sessionId && state.
|
|
2339
|
-
_endSessionOnChain(prev.sessionId, state.
|
|
2387
|
+
if (prev?.sessionId && state._wallet) {
|
|
2388
|
+
_endSessionOnChain(prev.sessionId, state._wallet, state._feeGranter).catch(e => {
|
|
2340
2389
|
console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
|
|
2341
2390
|
});
|
|
2342
2391
|
}
|
|
@@ -2348,7 +2397,7 @@ async function _disconnectInternal(state, { endSession }) {
|
|
|
2348
2397
|
}
|
|
2349
2398
|
} finally {
|
|
2350
2399
|
// ALWAYS clear connection state — even if teardown threw
|
|
2351
|
-
state.
|
|
2400
|
+
state._wallet = null;
|
|
2352
2401
|
state._feeGranter = null;
|
|
2353
2402
|
state.connection = null;
|
|
2354
2403
|
clearState();
|
|
@@ -2415,11 +2464,14 @@ export async function disconnectAndEndSession() {
|
|
|
2415
2464
|
* End a session on-chain. Best-effort, fire-and-forget.
|
|
2416
2465
|
* Prevents stale session accumulation on nodes.
|
|
2417
2466
|
* @param {string|bigint} sessionId - Session ID to end
|
|
2418
|
-
* @param {
|
|
2467
|
+
* @param {object} walletObj - { wallet, account } from cachedCreateWallet (NOT the mnemonic).
|
|
2468
|
+
* v37 (security): the BIP-39 phrase is no longer kept on ConnectionState.
|
|
2469
|
+
* @param {string} [feeGranter] - Optional fee grant payer for 0-P2P agents
|
|
2419
2470
|
* @private
|
|
2420
2471
|
*/
|
|
2421
|
-
async function _endSessionOnChain(sessionId,
|
|
2422
|
-
|
|
2472
|
+
async function _endSessionOnChain(sessionId, walletObj, feeGranter = null) {
|
|
2473
|
+
if (!walletObj) throw new SentinelError(ErrorCodes.INVALID_OPTIONS, '_endSessionOnChain requires a wallet object');
|
|
2474
|
+
const { wallet, account } = walletObj;
|
|
2423
2475
|
const client = await tryWithFallback(
|
|
2424
2476
|
RPC_ENDPOINTS,
|
|
2425
2477
|
async (url) => createClient(url, wallet),
|
|
@@ -2465,7 +2517,7 @@ export async function recoverSession(opts) {
|
|
|
2465
2517
|
if (!opts?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'recoverSession requires opts.nodeAddress');
|
|
2466
2518
|
if (!opts?.mnemonic) throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, 'recoverSession requires opts.mnemonic');
|
|
2467
2519
|
|
|
2468
|
-
const logFn = opts.log ||
|
|
2520
|
+
const logFn = opts.log || defaultLog;
|
|
2469
2521
|
const onProgress = opts.onProgress || null;
|
|
2470
2522
|
const sessionId = BigInt(opts.sessionId);
|
|
2471
2523
|
const timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };
|
package/operator.js
CHANGED
|
@@ -81,6 +81,16 @@ export {
|
|
|
81
81
|
computeFeeGrantGasCosts,
|
|
82
82
|
} from './cosmjs-setup.js';
|
|
83
83
|
|
|
84
|
+
export {
|
|
85
|
+
batchRevokeFeeGrants,
|
|
86
|
+
} from './operator/batch-revoke.js';
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
queryFeeGrantHistory,
|
|
90
|
+
decodeFeeGrantEvent,
|
|
91
|
+
attr,
|
|
92
|
+
} from './operator/feegrant-history.js';
|
|
93
|
+
|
|
84
94
|
// ─── Authz ──────────────────────────────────────────────────────────────────
|
|
85
95
|
|
|
86
96
|
export {
|
|
@@ -134,3 +144,17 @@ export {
|
|
|
134
144
|
encodeMsgUpdateSubscription,
|
|
135
145
|
encodeMsgUpdateSession,
|
|
136
146
|
} from './v3protocol.js';
|
|
147
|
+
|
|
148
|
+
// ─── Lease Batch Utilities ───────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
autoLeaseNode,
|
|
152
|
+
batchLeaseNodes,
|
|
153
|
+
} from './operator/auto-lease.js';
|
|
154
|
+
// ─── Plan Ownership Pre-Flight ──────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export {
|
|
157
|
+
assertPlanOwnership,
|
|
158
|
+
PlanOwnershipError,
|
|
159
|
+
walletToProviderAddr,
|
|
160
|
+
} from './operator/plan-ownership.js';
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blue-js-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Decentralized VPN SDK for the Sentinel P2P bandwidth network. WireGuard + V2Ray tunnels, Cosmos blockchain, 900+ nodes. Tested on Windows. macOS/Linux support included but untested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"types": "types/index.d.ts",
|
|
8
8
|
"bin": {
|
|
9
|
-
"sentinel": "
|
|
9
|
+
"sentinel": "cli/index.js"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
@@ -63,6 +63,9 @@
|
|
|
63
63
|
"proto:generate": "npx protoc --ts_proto_out=generated --ts_proto_opt=esModuleInterop=true --ts_proto_opt=env=node --ts_proto_opt=outputTypeRegistry=true --proto_path=proto proto/sentinel/types/v1/*.proto proto/sentinel/node/v3/*.proto proto/sentinel/session/v3/*.proto proto/sentinel/plan/v3/*.proto proto/sentinel/subscription/v3/*.proto proto/sentinel/lease/v1/*.proto proto/sentinel/provider/v2/*.proto",
|
|
64
64
|
"test": "node test/smoke.js",
|
|
65
65
|
"test:exports": "node -e \"import('./index.js').then(m => console.log(Object.keys(m).length + ' exports OK'))\"",
|
|
66
|
+
"test:privy": "node test/privy-cosmos-signer.test.mjs && node test/privy-client-integration.test.mjs",
|
|
67
|
+
"test:privy:live": "node test/privy-live-chain.test.mjs",
|
|
68
|
+
"test:privy:server": "node test/privy-real-server.test.mjs",
|
|
66
69
|
"postinstall": "node bin/setup.js || true"
|
|
67
70
|
},
|
|
68
71
|
"keywords": [
|
|
@@ -86,18 +89,18 @@
|
|
|
86
89
|
"license": "MIT",
|
|
87
90
|
"repository": {
|
|
88
91
|
"type": "git",
|
|
89
|
-
"url": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk.git"
|
|
92
|
+
"url": "git+https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk.git"
|
|
90
93
|
},
|
|
91
94
|
"bugs": {
|
|
92
95
|
"url": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk/issues"
|
|
93
96
|
},
|
|
94
97
|
"homepage": "https://github.com/Sentinel-Autonomybuilder/sentinel-dvpn-sdk#readme",
|
|
95
98
|
"dependencies": {
|
|
96
|
-
"@cosmjs/amino": "0.32.
|
|
97
|
-
"@cosmjs/crypto": "0.32.
|
|
98
|
-
"@cosmjs/encoding": "0.32.
|
|
99
|
-
"@cosmjs/proto-signing": "0.32.
|
|
100
|
-
"@cosmjs/stargate": "0.32.
|
|
99
|
+
"@cosmjs/amino": "0.32.4",
|
|
100
|
+
"@cosmjs/crypto": "0.32.4",
|
|
101
|
+
"@cosmjs/encoding": "0.32.4",
|
|
102
|
+
"@cosmjs/proto-signing": "0.32.4",
|
|
103
|
+
"@cosmjs/stargate": "0.32.4",
|
|
101
104
|
"@noble/curves": "^1.4.0",
|
|
102
105
|
"axios": "^1.7.0",
|
|
103
106
|
"dotenv": "^16.4.0",
|
package/session-manager.js
CHANGED
|
@@ -329,3 +329,71 @@ export class SessionManager {
|
|
|
329
329
|
if (this._logger) this._logger(msg);
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
|
+
|
|
333
|
+
// ─── Multi-message tx session extraction ─────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Extract session IDs keyed by node_address from a multi-message transaction.
|
|
337
|
+
*
|
|
338
|
+
* Background: a single tx can carry up to N MsgStartSession messages. The chain
|
|
339
|
+
* emits a `session_id` per session, but on most chain heights the events do NOT
|
|
340
|
+
* include `node_address` alongside the `session_id`. Confirmed empirically
|
|
341
|
+
* 2026-03-23. Naively pairing events to messages by index causes address
|
|
342
|
+
* mismatch, so any session_id without a colocated node_address is reported as
|
|
343
|
+
* an "orphan" — the caller MUST resolve it to a node by querying the chain.
|
|
344
|
+
*
|
|
345
|
+
* Returns a Map with two non-enumerable hint fields attached:
|
|
346
|
+
* - `_orphanIds` Array<bigint> session IDs needing chain lookup
|
|
347
|
+
* - `_needsChainLookup` boolean true if any expected node is unmapped
|
|
348
|
+
*
|
|
349
|
+
* @param {{ events?: Array }} txResult - Tx broadcast result (RPC or LCD shape)
|
|
350
|
+
* @param {string[]} [nodeAddrs] - Expected node addresses (used to compute
|
|
351
|
+
* `_needsChainLookup`). If omitted, the flag is true whenever orphans exist.
|
|
352
|
+
* @returns {Map<string, bigint> & { _orphanIds: bigint[], _needsChainLookup: boolean }}
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* const map = extractSessionMap(txResult, batch.map(b => b.node.address));
|
|
356
|
+
* if (map._needsChainLookup) {
|
|
357
|
+
* for (const sid of map._orphanIds) {
|
|
358
|
+
* const session = await querySessionById(client, sid);
|
|
359
|
+
* map.set(session.nodeAddress, sid);
|
|
360
|
+
* }
|
|
361
|
+
* }
|
|
362
|
+
*/
|
|
363
|
+
export function extractSessionMap(txResult, nodeAddrs) {
|
|
364
|
+
const map = new Map();
|
|
365
|
+
const orphanIds = [];
|
|
366
|
+
|
|
367
|
+
for (const event of (txResult?.events || [])) {
|
|
368
|
+
if (!/session/i.test(event.type)) continue;
|
|
369
|
+
let sessionId = null;
|
|
370
|
+
let nodeAddr = null;
|
|
371
|
+
for (const attr of (event.attributes || [])) {
|
|
372
|
+
const k = typeof attr.key === 'string'
|
|
373
|
+
? attr.key
|
|
374
|
+
: Buffer.from(attr.key, 'base64').toString('utf8');
|
|
375
|
+
const rawV = typeof attr.value === 'string'
|
|
376
|
+
? attr.value
|
|
377
|
+
: Buffer.from(attr.value, 'base64').toString('utf8');
|
|
378
|
+
const clean = rawV.replace(/"/g, '');
|
|
379
|
+
if (k === 'session_id' || k === 'id') {
|
|
380
|
+
try {
|
|
381
|
+
const id = BigInt(clean);
|
|
382
|
+
if (id > 0n) sessionId = id;
|
|
383
|
+
} catch { /* not a numeric id; skip */ }
|
|
384
|
+
}
|
|
385
|
+
if (k === 'node_address') nodeAddr = clean;
|
|
386
|
+
}
|
|
387
|
+
if (sessionId && nodeAddr) {
|
|
388
|
+
map.set(nodeAddr, sessionId);
|
|
389
|
+
} else if (sessionId) {
|
|
390
|
+
orphanIds.push(sessionId);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Chain events NEVER include node_address on most heights. Caller resolves.
|
|
395
|
+
map._orphanIds = orphanIds;
|
|
396
|
+
map._needsChainLookup = orphanIds.length > 0
|
|
397
|
+
&& map.size < (nodeAddrs?.length || Number.POSITIVE_INFINITY);
|
|
398
|
+
return map;
|
|
399
|
+
}
|
package/speedtest.js
CHANGED
|
@@ -565,3 +565,142 @@ export function compareSpeedTests(before, after) {
|
|
|
565
565
|
};
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
// ─── Post-Tunnel Google Accessibility Checks ────────────────────────────────
|
|
569
|
+
//
|
|
570
|
+
// Some nodes route Cloudflare fine but block Google (region-specific egress
|
|
571
|
+
// filtering). Speedtest passing != general internet works. These helpers do
|
|
572
|
+
// a cheap, latency-only HTTPS hit against `google.com` after the tunnel is
|
|
573
|
+
// up — useful as a fast pre-flight before a full speedtest, or as a separate
|
|
574
|
+
// "general internet works" signal in audit results.
|
|
575
|
+
//
|
|
576
|
+
// Two flavors:
|
|
577
|
+
// - checkGoogleDirect: for tunnels where all traffic is tunneled (WireGuard)
|
|
578
|
+
// - checkGoogleViaSocks5: for tunnels where traffic goes via local SOCKS5 (V2Ray)
|
|
579
|
+
//
|
|
580
|
+
// Direct check uses an external resolver (8.8.8.8 / 1.1.1.1) to resolve
|
|
581
|
+
// `www.google.com` BEFORE the tunnel may have working DNS, then issues HTTPS
|
|
582
|
+
// to the IP with `Host: www.google.com` + SNI. Works on tunnels that don't
|
|
583
|
+
// route DNS or that point to dead resolvers.
|
|
584
|
+
|
|
585
|
+
const GOOGLE_HOST = 'www.google.com';
|
|
586
|
+
const GOOGLE_DNS_CACHE_TTL = 5 * 60_000;
|
|
587
|
+
let cachedGoogleIp = null;
|
|
588
|
+
let cachedGoogleTime = 0;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Resolve `www.google.com` to an A record using public resolvers, with
|
|
592
|
+
* fallback to the system resolver and finally `dns.lookup`. Result is cached
|
|
593
|
+
* for {@link GOOGLE_DNS_CACHE_TTL} ms across calls in the same process.
|
|
594
|
+
*
|
|
595
|
+
* @returns {Promise<string|null>} IPv4 address or null if every resolver failed
|
|
596
|
+
*/
|
|
597
|
+
export async function resolveGoogleIp() {
|
|
598
|
+
if (cachedGoogleIp && Date.now() - cachedGoogleTime < GOOGLE_DNS_CACHE_TTL) return cachedGoogleIp;
|
|
599
|
+
try {
|
|
600
|
+
const resolver = new dns.Resolver();
|
|
601
|
+
resolver.setServers(['8.8.8.8', '1.1.1.1']);
|
|
602
|
+
const addrs = await new Promise((resolve, reject) => {
|
|
603
|
+
resolver.resolve4(GOOGLE_HOST, (err, addresses) => err ? reject(err) : resolve(addresses));
|
|
604
|
+
});
|
|
605
|
+
if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
|
|
606
|
+
} catch { }
|
|
607
|
+
try {
|
|
608
|
+
const addrs = await dns.promises.resolve4(GOOGLE_HOST);
|
|
609
|
+
if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
|
|
610
|
+
} catch { }
|
|
611
|
+
try {
|
|
612
|
+
const { address } = await dns.promises.lookup(GOOGLE_HOST);
|
|
613
|
+
cachedGoogleIp = address;
|
|
614
|
+
cachedGoogleTime = Date.now();
|
|
615
|
+
return cachedGoogleIp;
|
|
616
|
+
} catch { }
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Check if `google.com` is reachable through the ambient network path
|
|
622
|
+
* (typically a WireGuard tunnel where all traffic is tunneled). Tries the
|
|
623
|
+
* resolved IP first (with `Host` header + SNI), then falls back to the
|
|
624
|
+
* hostname directly. Any successful TLS handshake counts as reachable.
|
|
625
|
+
*
|
|
626
|
+
* @param {number} [timeoutMs=10000]
|
|
627
|
+
* @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
|
|
628
|
+
*/
|
|
629
|
+
export async function checkGoogleDirect(timeoutMs = 10_000) {
|
|
630
|
+
const start = Date.now();
|
|
631
|
+
const targetIp = await resolveGoogleIp();
|
|
632
|
+
const targets = [];
|
|
633
|
+
if (targetIp) targets.push(`https://${targetIp}/`);
|
|
634
|
+
targets.push(`https://${GOOGLE_HOST}/`);
|
|
635
|
+
|
|
636
|
+
for (const url of targets) {
|
|
637
|
+
try {
|
|
638
|
+
await new Promise((resolve, reject) => {
|
|
639
|
+
const parsed = new URL(url);
|
|
640
|
+
const options = {
|
|
641
|
+
hostname: parsed.hostname,
|
|
642
|
+
path: '/',
|
|
643
|
+
method: 'GET',
|
|
644
|
+
rejectUnauthorized: false,
|
|
645
|
+
agent: false,
|
|
646
|
+
headers: { Host: GOOGLE_HOST },
|
|
647
|
+
servername: GOOGLE_HOST,
|
|
648
|
+
};
|
|
649
|
+
const req = https.get(options, (res) => {
|
|
650
|
+
res.destroy();
|
|
651
|
+
resolve();
|
|
652
|
+
});
|
|
653
|
+
req.on('error', reject);
|
|
654
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error('timeout')); });
|
|
655
|
+
});
|
|
656
|
+
return {
|
|
657
|
+
googleAccessible: true,
|
|
658
|
+
googleLatencyMs: Date.now() - start,
|
|
659
|
+
googleError: null,
|
|
660
|
+
};
|
|
661
|
+
} catch { }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
googleAccessible: false,
|
|
666
|
+
googleLatencyMs: null,
|
|
667
|
+
googleError: 'Google unreachable through tunnel',
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Check if `google.com` is reachable through a V2Ray SOCKS5 proxy on
|
|
673
|
+
* localhost. Required because native Node `fetch` silently ignores SOCKS
|
|
674
|
+
* proxy agents — must use axios + SocksProxyAgent.
|
|
675
|
+
*
|
|
676
|
+
* @param {number} proxyPort - Local V2Ray SOCKS5 port (e.g. 1080)
|
|
677
|
+
* @param {number} [timeoutMs=10000]
|
|
678
|
+
* @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
|
|
679
|
+
*/
|
|
680
|
+
export async function checkGoogleViaSocks5(proxyPort, timeoutMs = 10_000) {
|
|
681
|
+
const start = Date.now();
|
|
682
|
+
const agent = new SocksProxyAgent(`socks5://127.0.0.1:${proxyPort}`);
|
|
683
|
+
try {
|
|
684
|
+
await axios.get(`https://${GOOGLE_HOST}/`, {
|
|
685
|
+
timeout: timeoutMs,
|
|
686
|
+
httpAgent: agent,
|
|
687
|
+
httpsAgent: agent,
|
|
688
|
+
maxRedirects: 2,
|
|
689
|
+
validateStatus: () => true,
|
|
690
|
+
});
|
|
691
|
+
return {
|
|
692
|
+
googleAccessible: true,
|
|
693
|
+
googleLatencyMs: Date.now() - start,
|
|
694
|
+
googleError: null,
|
|
695
|
+
};
|
|
696
|
+
} catch (err) {
|
|
697
|
+
return {
|
|
698
|
+
googleAccessible: false,
|
|
699
|
+
googleLatencyMs: null,
|
|
700
|
+
googleError: err.message || 'Google unreachable through SOCKS5',
|
|
701
|
+
};
|
|
702
|
+
} finally {
|
|
703
|
+
agent.destroy();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
package/test-all-logic.js
CHANGED
|
@@ -29,12 +29,14 @@ t('formatUptime 0', sdk.formatUptime(0) === '0s');
|
|
|
29
29
|
|
|
30
30
|
// ═══ ADDRESS CONVERSION (6) ═══
|
|
31
31
|
console.log('═══ ADDRESS CONVERSION ═══');
|
|
32
|
-
|
|
32
|
+
// Valid sent1 address derived from BIP39 test mnemonic ("abandon abandon...about")
|
|
33
|
+
const ADDR = 'sent19rl4cm2hmr8afy4kldpxz3fka4jguq0a8mmym6';
|
|
34
|
+
t('shortAddress truncates', sdk.shortAddress(ADDR).includes('...'));
|
|
33
35
|
t('shortAddress short passthrough', sdk.shortAddress('sent1abc') === 'sent1abc');
|
|
34
|
-
t('sentToSentprov', sdk.sentToSentprov(
|
|
35
|
-
t('sentToSentnode', sdk.sentToSentnode(
|
|
36
|
-
t('sentprovToSent', sdk.sentprovToSent(sdk.sentToSentprov(
|
|
37
|
-
t('isSameKey cross-prefix', sdk.isSameKey(
|
|
36
|
+
t('sentToSentprov', sdk.sentToSentprov(ADDR).startsWith('sentprov'));
|
|
37
|
+
t('sentToSentnode', sdk.sentToSentnode(ADDR).startsWith('sentnode'));
|
|
38
|
+
t('sentprovToSent', sdk.sentprovToSent(sdk.sentToSentprov(ADDR)).startsWith('sent1'));
|
|
39
|
+
t('isSameKey cross-prefix', sdk.isSameKey(ADDR, sdk.sentToSentprov(ADDR)));
|
|
38
40
|
|
|
39
41
|
// ═══ VALIDATION (7) ═══
|
|
40
42
|
console.log('═══ VALIDATION ═══');
|
|
@@ -78,7 +80,7 @@ t('no duplicate DNS', new Set(sdk.resolveDnsServers().split(', ')).size === sdk.
|
|
|
78
80
|
|
|
79
81
|
// ═══ ERROR SYSTEM (20) ═══
|
|
80
82
|
console.log('═══ ERROR SYSTEM ═══');
|
|
81
|
-
t('
|
|
83
|
+
t('42 error codes', Object.values(sdk.ErrorCodes).length === 42);
|
|
82
84
|
t('all have messages', Object.values(sdk.ErrorCodes).every(c => sdk.userMessage(c) !== 'An unexpected error occurred.'));
|
|
83
85
|
t('unknown default', sdk.userMessage('FAKE') === 'An unexpected error occurred.');
|
|
84
86
|
t('INSUFFICIENT fatal', sdk.ERROR_SEVERITY[sdk.ErrorCodes.INSUFFICIENT_BALANCE] === 'fatal');
|