blue-js-sdk 2.3.0 → 2.6.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/README.md +3 -3
- package/batch.js +2 -2
- package/chain/authz.js +1 -9
- package/chain/fee-grants.js +199 -3
- package/chain/index.js +36 -167
- package/chain/queries.js +118 -7
- package/chain/rpc.js +58 -2
- package/client/index.js +1 -3
- package/client.js +17 -1
- package/connection/connect.js +17 -5
- package/connection/disconnect.js +86 -10
- package/connection/discovery.js +11 -11
- package/connection/index.js +2 -0
- package/cosmjs-setup.js +30 -153
- package/defaults.js +1 -1
- package/index.js +5 -1
- package/node-connect.js +118 -25
- package/operator.js +2 -0
- package/package.json +2 -5
- package/pricing/index.js +3 -26
- package/types/index.d.ts +2 -2
- package/ai-path/ADMIN-ELEVATION.md +0 -116
- package/ai-path/AI-MANIFESTO.md +0 -185
- package/ai-path/BREAKING.md +0 -74
- package/ai-path/CHECKLIST.md +0 -619
- package/ai-path/CONNECTION-STEPS.md +0 -724
- package/ai-path/DECISION-TREE.md +0 -422
- package/ai-path/DEPENDENCIES.md +0 -459
- package/ai-path/E2E-FLOW.md +0 -1707
- package/ai-path/FAILURES.md +0 -410
- package/ai-path/GUIDE.md +0 -1315
- package/ai-path/README.md +0 -599
- package/ai-path/SPLIT-TUNNEL.md +0 -266
- package/ai-path/cli.js +0 -548
- package/ai-path/connect.js +0 -1028
- package/ai-path/discover.js +0 -178
- package/ai-path/environment.js +0 -266
- package/ai-path/errors.js +0 -86
- package/ai-path/examples/autonomous-agent.mjs +0 -220
- package/ai-path/examples/multi-region.mjs +0 -174
- package/ai-path/examples/one-shot.mjs +0 -31
- package/ai-path/index.js +0 -79
- package/ai-path/pricing.js +0 -137
- package/ai-path/recommend.js +0 -413
- package/ai-path/run-admin.vbs +0 -25
- package/ai-path/setup.js +0 -291
- package/ai-path/wallet.js +0 -137
package/chain/queries.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
rpcQueryBalance,
|
|
33
33
|
rpcQueryProvider as _rpcQueryProvider,
|
|
34
34
|
rpcQueryAuthzGrants as _rpcQueryAuthzGrants,
|
|
35
|
+
rpcGetTxByHash,
|
|
35
36
|
} from './rpc.js';
|
|
36
37
|
|
|
37
38
|
// Re-export for convenience
|
|
@@ -56,6 +57,15 @@ async function getRpcClient() {
|
|
|
56
57
|
return _rpcClientPromise;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Clear the cached RPC query client. Called during process cleanup
|
|
62
|
+
* to ensure WebSocket connections are properly closed.
|
|
63
|
+
*/
|
|
64
|
+
export function resetQueryRpcCache() {
|
|
65
|
+
_rpcClient = null;
|
|
66
|
+
_rpcClientPromise = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
// ─── Query Helpers ───────────────────────────────────────────────────────────
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -72,8 +82,22 @@ export async function getBalance(client, address) {
|
|
|
72
82
|
* Find an existing active session for a wallet+node pair.
|
|
73
83
|
* Returns session ID (BigInt) or null. Use this to avoid double-paying.
|
|
74
84
|
* RPC-first with LCD fallback.
|
|
85
|
+
*
|
|
86
|
+
* Dedup: if multiple active sessions exist for the same node_address (stale
|
|
87
|
+
* duplicates from crashes or multi-client wallets), the one with the HIGHEST
|
|
88
|
+
* session ID is returned. All lower-ID duplicates are passed to `onStaleDuplicate`
|
|
89
|
+
* (if provided) for fire-and-forget cancellation.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} lcdUrl - LCD endpoint URL
|
|
92
|
+
* @param {string} walletAddr - sent1... wallet address
|
|
93
|
+
* @param {string} nodeAddr - sentnode1... node address
|
|
94
|
+
* @param {object} [opts]
|
|
95
|
+
* @param {function} [opts.onStaleDuplicate] - Called with (BigInt sessionId) for each
|
|
96
|
+
* stale lower-ID duplicate session. Caller is responsible for fire-and-forget
|
|
97
|
+
* MsgCancelSession. Keeps chain/queries.js dependency-free of signing/broadcast logic.
|
|
98
|
+
* @returns {Promise<BigInt|null>}
|
|
75
99
|
*/
|
|
76
|
-
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
100
|
+
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts = {}) {
|
|
77
101
|
let sessions;
|
|
78
102
|
|
|
79
103
|
// RPC-first: returns decoded, flat session objects
|
|
@@ -93,6 +117,8 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
|
93
117
|
});
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
// Collect all non-exhausted active sessions for this node
|
|
121
|
+
const matching = [];
|
|
96
122
|
for (const s of sessions) {
|
|
97
123
|
if ((s.node_address || s.node) !== nodeAddr) continue;
|
|
98
124
|
// RPC returns status as number (1=active), LCD as string
|
|
@@ -102,9 +128,22 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
|
102
128
|
if (acct && acct !== walletAddr) continue;
|
|
103
129
|
const maxBytes = parseInt(s.max_bytes || '0');
|
|
104
130
|
const used = parseInt(s.download_bytes || '0') + parseInt(s.upload_bytes || '0');
|
|
105
|
-
if (maxBytes === 0 || used < maxBytes)
|
|
131
|
+
if (maxBytes === 0 || used < maxBytes) matching.push(BigInt(s.id));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (matching.length === 0) return null;
|
|
135
|
+
|
|
136
|
+
// Sort descending — highest session ID is the freshest (most recent MsgStartSession)
|
|
137
|
+
matching.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));
|
|
138
|
+
|
|
139
|
+
// Dedup: cancel stale lower-ID duplicates (fire-and-forget via caller callback)
|
|
140
|
+
if (matching.length > 1 && typeof opts.onStaleDuplicate === 'function') {
|
|
141
|
+
for (let i = 1; i < matching.length; i++) {
|
|
142
|
+
opts.onStaleDuplicate(matching[i]);
|
|
143
|
+
}
|
|
106
144
|
}
|
|
107
|
-
|
|
145
|
+
|
|
146
|
+
return matching[0];
|
|
108
147
|
}
|
|
109
148
|
|
|
110
149
|
/**
|
|
@@ -344,9 +383,9 @@ export async function querySessionById(lcdUrl, sessionId) {
|
|
|
344
383
|
}
|
|
345
384
|
} catch { /* fall through to LCD */ }
|
|
346
385
|
|
|
347
|
-
// LCD fallback
|
|
386
|
+
// LCD fallback (with endpoint failover via lcdQuery)
|
|
348
387
|
try {
|
|
349
|
-
const data = await
|
|
388
|
+
const data = await lcdQuery(`/sentinel/session/v3/sessions/${sessionId}`, { lcdUrl });
|
|
350
389
|
const raw = data?.session;
|
|
351
390
|
if (!raw) return null;
|
|
352
391
|
return flattenSession(raw);
|
|
@@ -372,9 +411,9 @@ export async function querySessionAllocation(lcdUrl, sessionId) {
|
|
|
372
411
|
} catch { /* fall through */ }
|
|
373
412
|
|
|
374
413
|
if (!s) {
|
|
375
|
-
// LCD fallback
|
|
414
|
+
// LCD fallback (with endpoint failover via lcdQuery)
|
|
376
415
|
try {
|
|
377
|
-
const data = await
|
|
416
|
+
const data = await lcdQuery(`/sentinel/session/v3/sessions/${sessionId}`, { lcdUrl });
|
|
378
417
|
s = data.session?.base_session || data.session || null;
|
|
379
418
|
} catch { return null; }
|
|
380
419
|
}
|
|
@@ -832,3 +871,75 @@ export function saveVpnSettings(settings) {
|
|
|
832
871
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
833
872
|
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
834
873
|
}
|
|
874
|
+
|
|
875
|
+
// ─── TX Hash Lookup (RPC-first, LCD fallback) ───────────────────────────────
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Fetch a transaction by hash. RPC is tried first; if it fails or the TX is
|
|
879
|
+
* not found, falls back to the LCD REST endpoint.
|
|
880
|
+
*
|
|
881
|
+
* Accepts bare hex or 0x-prefixed hex for the hash.
|
|
882
|
+
* Returns the same normalised shape regardless of source:
|
|
883
|
+
* { hash, height, code, rawLog, events, gasUsed, gasWanted }
|
|
884
|
+
*
|
|
885
|
+
* Use this to re-fetch TX events after a crash/restart or from a different
|
|
886
|
+
* process (CosmJS only returns DeliverTxResponse inline from signAndBroadcast).
|
|
887
|
+
*
|
|
888
|
+
* @param {string} txHash - Transaction hash (bare hex or 0x-prefixed)
|
|
889
|
+
* @param {object} [opts]
|
|
890
|
+
* @param {string} [opts.rpcUrl] - RPC endpoint (uses cached client if omitted)
|
|
891
|
+
* @param {string} [opts.lcdUrl] - LCD endpoint for fallback
|
|
892
|
+
* @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string } | null>}
|
|
893
|
+
*/
|
|
894
|
+
export async function getTxByHash(txHash, opts = {}) {
|
|
895
|
+
const hex = txHash.replace(/^0x/i, '').toUpperCase();
|
|
896
|
+
|
|
897
|
+
// ── RPC-first ──────────────────────────────────────────────────────────────
|
|
898
|
+
try {
|
|
899
|
+
let rpc;
|
|
900
|
+
if (opts.rpcUrl) {
|
|
901
|
+
const { createRpcQueryClient } = await import('./rpc.js');
|
|
902
|
+
rpc = await createRpcQueryClient(opts.rpcUrl);
|
|
903
|
+
} else {
|
|
904
|
+
rpc = await getRpcClient();
|
|
905
|
+
}
|
|
906
|
+
if (rpc?.tmClient) {
|
|
907
|
+
const result = await rpcGetTxByHash(rpc.tmClient, hex);
|
|
908
|
+
return result;
|
|
909
|
+
}
|
|
910
|
+
} catch (rpcErr) {
|
|
911
|
+
// "tx not found" from RPC → fall through to LCD
|
|
912
|
+
const msg = rpcErr?.message || '';
|
|
913
|
+
if (!msg.toLowerCase().includes('not found') && !msg.toLowerCase().includes('404')) {
|
|
914
|
+
// Real connectivity error — still fall through, LCD may succeed
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── LCD fallback ───────────────────────────────────────────────────────────
|
|
919
|
+
try {
|
|
920
|
+
const doLcd = async (baseUrl) => {
|
|
921
|
+
const data = await lcdQuery(`/cosmos/tx/v1beta1/txs/${hex}`, { lcdUrl: baseUrl });
|
|
922
|
+
const txResp = data?.tx_response;
|
|
923
|
+
if (!txResp) return null;
|
|
924
|
+
const events = (txResp.events || []).map(ev => ({
|
|
925
|
+
type: ev.type,
|
|
926
|
+
attributes: (ev.attributes || []).map(attr => ({
|
|
927
|
+
key: attr.key,
|
|
928
|
+
value: attr.value,
|
|
929
|
+
})),
|
|
930
|
+
}));
|
|
931
|
+
return {
|
|
932
|
+
hash: (txResp.txhash || hex).toUpperCase(),
|
|
933
|
+
height: parseInt(txResp.height || '0', 10),
|
|
934
|
+
code: txResp.code || 0,
|
|
935
|
+
rawLog: txResp.raw_log || '',
|
|
936
|
+
events,
|
|
937
|
+
gasUsed: String(txResp.gas_used || '0'),
|
|
938
|
+
gasWanted: String(txResp.gas_wanted || '0'),
|
|
939
|
+
};
|
|
940
|
+
};
|
|
941
|
+
if (opts.lcdUrl) return await doLcd(opts.lcdUrl);
|
|
942
|
+
const { result } = await tryWithFallback(LCD_ENDPOINTS, doLcd, `getTxByHash ${hex}`);
|
|
943
|
+
return result;
|
|
944
|
+
} catch { return null; }
|
|
945
|
+
}
|
package/chain/rpc.js
CHANGED
|
@@ -402,11 +402,17 @@ function decodeAllocation(fields) {
|
|
|
402
402
|
/**
|
|
403
403
|
* Query active nodes via RPC.
|
|
404
404
|
*
|
|
405
|
+
* NOTE ON PAGINATION: Sentinel v3's `QueryNodes` truncates at the requested
|
|
406
|
+
* `limit` and does NOT emit `pagination.next_key`. A standard Cosmos
|
|
407
|
+
* "loop while next_key is non-empty" pattern terminates on the first call
|
|
408
|
+
* and silently loses data. Request above the chain's current hard ceiling
|
|
409
|
+
* (~1048 active nodes as of 2026-04). Default raised to 10000.
|
|
410
|
+
*
|
|
405
411
|
* @param {{ queryClient: QueryClient }} client - From createRpcQueryClient()
|
|
406
412
|
* @param {{ status?: number, limit?: number }} [opts]
|
|
407
413
|
* @returns {Promise<Array<{ address: string, gigabyte_prices: Array, hourly_prices: Array, remote_addrs: string[], status: number }>>}
|
|
408
414
|
*/
|
|
409
|
-
export async function rpcQueryNodes(client, { status = 1, limit =
|
|
415
|
+
export async function rpcQueryNodes(client, { status = 1, limit = 10000 } = {}) {
|
|
410
416
|
const path = '/sentinel.node.v3.QueryService/QueryNodes';
|
|
411
417
|
const request = concat([
|
|
412
418
|
encodeEnum(1, status), // status field
|
|
@@ -446,12 +452,18 @@ export async function rpcQueryNode(client, address) {
|
|
|
446
452
|
/**
|
|
447
453
|
* Query nodes linked to a plan via RPC.
|
|
448
454
|
*
|
|
455
|
+
* NOTE ON PAGINATION: `QueryNodesForPlan` silently truncates at the requested
|
|
456
|
+
* `limit` with no `next_key`. Observed 2026-04: plan 36 has 803 active nodes
|
|
457
|
+
* but `limit=500` returns exactly 500 with no indication more exist. Default
|
|
458
|
+
* raised to 10000. If a plan grows beyond that, raise further — the chain's
|
|
459
|
+
* own ceiling is the effective limit.
|
|
460
|
+
*
|
|
449
461
|
* @param {{ queryClient: QueryClient }} client
|
|
450
462
|
* @param {number|bigint} planId
|
|
451
463
|
* @param {{ status?: number, limit?: number }} [opts]
|
|
452
464
|
* @returns {Promise<Array>}
|
|
453
465
|
*/
|
|
454
|
-
export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit =
|
|
466
|
+
export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit = 10000 } = {}) {
|
|
455
467
|
const path = '/sentinel.node.v3.QueryService/QueryNodesForPlan';
|
|
456
468
|
const request = concat([
|
|
457
469
|
encodeUint64(1, planId), // id
|
|
@@ -891,6 +903,50 @@ export async function rpcQueryProvider(client, provAddress) {
|
|
|
891
903
|
}
|
|
892
904
|
}
|
|
893
905
|
|
|
906
|
+
// ─── TX Hash Lookup ─────────────────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Fetch a transaction by hash via Tendermint RPC.
|
|
910
|
+
* Accepts bare hex (64 chars) or 0x-prefixed hex. Returns a normalized shape
|
|
911
|
+
* matching the LCD cosmos/tx/v1beta1/txs/{hash} response so callers don't
|
|
912
|
+
* need to handle both formats.
|
|
913
|
+
*
|
|
914
|
+
* @param {import('@cosmjs/tendermint-rpc').Tendermint37Client} tmClient - From createRpcQueryClient().tmClient
|
|
915
|
+
* @param {string} txHash - TX hash as hex string (bare or 0x-prefixed)
|
|
916
|
+
* @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string }>}
|
|
917
|
+
* @throws {Error} If the transaction is not found or RPC call fails
|
|
918
|
+
*/
|
|
919
|
+
export async function rpcGetTxByHash(tmClient, txHash) {
|
|
920
|
+
// Strip optional 0x prefix and normalise to upper-case for consistency
|
|
921
|
+
const hex = txHash.replace(/^0x/i, '').toUpperCase();
|
|
922
|
+
// Decode hex string → Uint8Array
|
|
923
|
+
const hashBytes = new Uint8Array(hex.length / 2);
|
|
924
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
925
|
+
hashBytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const response = await tmClient.tx({ hash: hashBytes });
|
|
929
|
+
|
|
930
|
+
// Normalise events: TxData.events is already decoded by CosmJS
|
|
931
|
+
const events = (response.result.events || []).map(ev => ({
|
|
932
|
+
type: ev.type,
|
|
933
|
+
attributes: (ev.attributes || []).map(attr => ({
|
|
934
|
+
key: attr.key,
|
|
935
|
+
value: attr.value,
|
|
936
|
+
})),
|
|
937
|
+
}));
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
hash: hex,
|
|
941
|
+
height: response.height,
|
|
942
|
+
code: response.result.code,
|
|
943
|
+
rawLog: response.result.log || '',
|
|
944
|
+
events,
|
|
945
|
+
gasUsed: String(response.result.gasUsed),
|
|
946
|
+
gasWanted: String(response.result.gasWanted),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
894
950
|
export async function rpcQueryBalance(client, address, denom = 'udvpn') {
|
|
895
951
|
const path = '/cosmos.bank.v1beta1.Query/Balance';
|
|
896
952
|
const request = concat([
|
package/client/index.js
CHANGED
|
@@ -33,9 +33,7 @@ import {
|
|
|
33
33
|
events as sdkEvents, ConnectionState,
|
|
34
34
|
} from '../connection/index.js';
|
|
35
35
|
import {
|
|
36
|
-
createWallet,
|
|
37
|
-
createSafeBroadcaster, getBalance, findExistingSession, fetchActiveNodes,
|
|
38
|
-
discoverPlanIds, resolveNodeUrl, lcd, MSG_TYPES,
|
|
36
|
+
createWallet, createClient, getBalance,
|
|
39
37
|
} from '../chain/index.js';
|
|
40
38
|
import { nodeStatusV3 } from '../protocol/index.js';
|
|
41
39
|
import { createNodeHttpsAgent, clearKnownNode, clearAllKnownNodes, getKnownNode } from '../security/index.js';
|
package/client.js
CHANGED
|
@@ -28,6 +28,7 @@ import { EventEmitter } from 'events';
|
|
|
28
28
|
import {
|
|
29
29
|
connectDirect, connectViaPlan, connectAuto, queryOnlineNodes,
|
|
30
30
|
disconnect as sdkDisconnect, disconnectState,
|
|
31
|
+
disconnectAndEndSession as sdkDisconnectAndEndSession, disconnectStateAndEndSession,
|
|
31
32
|
isConnected as sdkIsConnected, getStatus as sdkGetStatus,
|
|
32
33
|
registerCleanupHandlers, setSystemProxy, clearSystemProxy,
|
|
33
34
|
events as sdkEvents, ConnectionState,
|
|
@@ -125,13 +126,28 @@ export class SentinelClient extends EventEmitter {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
|
-
*
|
|
129
|
+
* Soft disconnect — tear down the tunnel, leave the on-chain session active.
|
|
130
|
+
*
|
|
131
|
+
* A subsequent connect() to the SAME node reuses the session (no new payment).
|
|
132
|
+
* Use for pause / temporary disconnect / network-change recovery.
|
|
133
|
+
* To settle the session and reclaim the deposit, use disconnectAndEndSession().
|
|
129
134
|
*/
|
|
130
135
|
async disconnect() {
|
|
131
136
|
await disconnectState(this._state);
|
|
132
137
|
this._connection = null;
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
|
|
142
|
+
*
|
|
143
|
+
* Settles the session after the ~2h window and refunds the unused deposit.
|
|
144
|
+
* Use when the user is done with this node (switching permanently or wants refund).
|
|
145
|
+
*/
|
|
146
|
+
async disconnectAndEndSession() {
|
|
147
|
+
await disconnectStateAndEndSession(this._state);
|
|
148
|
+
this._connection = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
135
151
|
/**
|
|
136
152
|
* Check if a VPN tunnel is currently active.
|
|
137
153
|
*/
|
package/connection/connect.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
events, _defaultState, progress, checkAborted,
|
|
10
10
|
warnIfNoCleanup, cachedCreateWallet, _recordMetric,
|
|
11
11
|
broadcastWithInactiveRetry, getConnectLock, setConnectLock,
|
|
12
|
-
getAbortConnect, setAbortConnect,
|
|
12
|
+
getAbortConnect, setAbortConnect, _endSessionOnChain,
|
|
13
13
|
} from './state.js';
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
import { createNodeHttpsAgent } from '../tls-trust.js';
|
|
32
32
|
import { disconnectWireGuard } from '../wireguard.js';
|
|
33
33
|
|
|
34
|
-
import { disconnectState } from './disconnect.js';
|
|
34
|
+
import { disconnectState, disconnectStateAndEndSession } from './disconnect.js';
|
|
35
35
|
import { queryOnlineNodes } from './discovery.js';
|
|
36
36
|
import {
|
|
37
37
|
recordNodeFailure, isCircuitOpen, configureCircuitBreaker,
|
|
@@ -79,8 +79,10 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
|
|
|
79
79
|
{ nodeAddress: state.connection?.nodeAddress });
|
|
80
80
|
}
|
|
81
81
|
const prev = state.connection;
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Hard disconnect: user is actively connecting to a different node,
|
|
83
|
+
// so the old session should be settled and the deposit refunded.
|
|
84
|
+
await disconnectStateAndEndSession(state);
|
|
85
|
+
if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Ended session on ${prev?.nodeAddress || 'previous node'} — connecting to new node`);
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
const onProgress = opts.onProgress || null;
|
|
@@ -397,7 +399,17 @@ export async function connectDirect(opts) {
|
|
|
397
399
|
if (!forceNewSession) {
|
|
398
400
|
progress(onProgress, logFn, 'session', 'Checking for existing session...');
|
|
399
401
|
checkAborted(signal);
|
|
400
|
-
sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress
|
|
402
|
+
sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress, {
|
|
403
|
+
// Dedup: if multiple active sessions exist for this node (stale duplicates from
|
|
404
|
+
// crashes or multi-client wallets), keep the highest-ID one. Silently cancel stale
|
|
405
|
+
// lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
|
|
406
|
+
onStaleDuplicate: (staleId) => {
|
|
407
|
+
logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
|
|
408
|
+
_endSessionOnChain(staleId, opts.mnemonic).catch(e => {
|
|
409
|
+
logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
});
|
|
401
413
|
if (sessionId && isSessionPoisoned(String(sessionId))) {
|
|
402
414
|
progress(onProgress, logFn, 'session', `Session ${sessionId} previously failed — skipping`);
|
|
403
415
|
sessionId = null;
|
package/connection/disconnect.js
CHANGED
|
@@ -28,13 +28,35 @@ import { queryNode, privKeyFromMnemonic } from '../cosmjs-setup.js';
|
|
|
28
28
|
import { createNodeHttpsAgent } from '../tls-trust.js';
|
|
29
29
|
|
|
30
30
|
// ─── Disconnect ──────────────────────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// TWO DISCONNECT PATHS — CHOOSE INTENT EXPLICITLY.
|
|
33
|
+
//
|
|
34
|
+
// Soft: disconnect() / disconnectState(state)
|
|
35
|
+
// - Tears down the local tunnel (WireGuard/V2Ray) and cleans up system state.
|
|
36
|
+
// - Leaves the on-chain session in status=1 (active).
|
|
37
|
+
// - Next connectDirect() to the SAME node reuses the session via
|
|
38
|
+
// findExistingSession — no new MsgStartSession TX, no new payment, bandwidth preserved.
|
|
39
|
+
// - Use when: user is pausing, network changed, closing the app temporarily.
|
|
40
|
+
//
|
|
41
|
+
// Hard: disconnectAndEndSession() / disconnectStateAndEndSession(state)
|
|
42
|
+
// - Tears down the tunnel AND broadcasts MsgCancelSession on chain.
|
|
43
|
+
// - Session moves status=1 → settling → refund after ~2h settlement window.
|
|
44
|
+
// - Use when: user is done with this node, switching nodes permanently,
|
|
45
|
+
// or wants the bandwidth deposit back.
|
|
46
|
+
//
|
|
47
|
+
// Internal: _disconnectInternal(state, { endSession })
|
|
48
|
+
// - Caller MUST pass endSession explicitly as true or false.
|
|
49
|
+
// - No default — forces intentional choice at every callsite.
|
|
31
50
|
|
|
32
51
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
52
|
+
* Internal disconnect implementation. Caller must explicitly pass endSession.
|
|
53
|
+
* @param {object} state - ConnectionState instance
|
|
54
|
+
* @param {{ endSession: boolean }} opts
|
|
55
|
+
* endSession: true → broadcast MsgCancelSession (hard disconnect)
|
|
56
|
+
* endSession: false → preserve on-chain session for reuse (soft disconnect)
|
|
57
|
+
* @private
|
|
35
58
|
*/
|
|
36
|
-
|
|
37
|
-
export async function disconnectState(state) {
|
|
59
|
+
async function _disconnectInternal(state, { endSession }) {
|
|
38
60
|
// v30: Signal any running connectAuto() retry loop to abort, and release the
|
|
39
61
|
// connection lock so the user can reconnect after disconnect completes.
|
|
40
62
|
setAbortConnect(true);
|
|
@@ -65,11 +87,18 @@ export async function disconnectState(state) {
|
|
|
65
87
|
try { disableDnsLeakPrevention(); } catch (e) { console.warn('[sentinel-sdk] DNS restore warning:', e.message); }
|
|
66
88
|
}
|
|
67
89
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
if (endSession) {
|
|
91
|
+
// Hard disconnect: broadcast MsgCancelSession — session settles and refunds deposit.
|
|
92
|
+
if (prev?.sessionId && state._mnemonic) {
|
|
93
|
+
_endSessionOnChain(prev.sessionId, state._mnemonic).catch(e => {
|
|
94
|
+
console.warn(`[sentinel-sdk] Failed to end session ${prev.sessionId} on chain: ${e.message}`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// Soft disconnect: leave session on chain in status=1 for reuse.
|
|
99
|
+
if (prev?.sessionId) {
|
|
100
|
+
console.log(`[sentinel-sdk] Session ${prev.sessionId} preserved on chain (status=1) for future reuse — no MsgCancelSession broadcast.`);
|
|
101
|
+
}
|
|
73
102
|
}
|
|
74
103
|
} finally {
|
|
75
104
|
// ALWAYS clear connection state — even if teardown threw
|
|
@@ -82,8 +111,55 @@ export async function disconnectState(state) {
|
|
|
82
111
|
}
|
|
83
112
|
}
|
|
84
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Soft disconnect — tear down the local tunnel, leave the on-chain session active.
|
|
116
|
+
*
|
|
117
|
+
* A subsequent connectDirect() to the SAME node will reuse the session via
|
|
118
|
+
* findExistingSession — no new MsgStartSession TX, no new payment, remaining
|
|
119
|
+
* bandwidth is preserved.
|
|
120
|
+
*
|
|
121
|
+
* Use when: user is pausing, network changed, or closing the app temporarily.
|
|
122
|
+
* To settle the session on-chain and reclaim the unused deposit, use
|
|
123
|
+
* disconnectAndEndSession() instead.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} [state] - ConnectionState instance (defaults to _defaultState)
|
|
126
|
+
*/
|
|
127
|
+
export async function disconnectState(state) {
|
|
128
|
+
return _disconnectInternal(state, { endSession: false });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Soft disconnect (default state) — tear down the tunnel, leave on-chain session active.
|
|
133
|
+
*
|
|
134
|
+
* @see disconnectState
|
|
135
|
+
*/
|
|
85
136
|
export async function disconnect() {
|
|
86
|
-
return
|
|
137
|
+
return _disconnectInternal(_defaultState, { endSession: false });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
|
|
142
|
+
*
|
|
143
|
+
* The session settles after the ~2h inactive_pending window. The node refunds
|
|
144
|
+
* the unused portion of the bandwidth deposit (for peer-to-peer sessions).
|
|
145
|
+
* For plan-based sessions, this stops metering against the plan allocation.
|
|
146
|
+
*
|
|
147
|
+
* Use when: user is done with this node (switching nodes permanently, ending
|
|
148
|
+
* their session, or wants the deposit back).
|
|
149
|
+
*
|
|
150
|
+
* @param {object} [state] - ConnectionState instance (defaults to _defaultState)
|
|
151
|
+
*/
|
|
152
|
+
export async function disconnectStateAndEndSession(state) {
|
|
153
|
+
return _disconnectInternal(state, { endSession: true });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Hard disconnect (default state) — tear down the tunnel AND broadcast MsgCancelSession.
|
|
158
|
+
*
|
|
159
|
+
* @see disconnectStateAndEndSession
|
|
160
|
+
*/
|
|
161
|
+
export async function disconnectAndEndSession() {
|
|
162
|
+
return _disconnectInternal(_defaultState, { endSession: true });
|
|
87
163
|
}
|
|
88
164
|
|
|
89
165
|
// ─── Cleanup Registration ───────────────────────────────────────────────────
|
package/connection/discovery.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Node Discovery — query, fetch, enrich, index, and score nodes.
|
|
3
3
|
*
|
|
4
|
-
* Handles
|
|
5
|
-
* and geographic indexing.
|
|
4
|
+
* Handles RPC-first queries (LCD fallback) for online nodes, caching,
|
|
5
|
+
* quality scoring, and geographic indexing.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -63,7 +63,7 @@ export function scoreNode(status) {
|
|
|
63
63
|
// ─── Query Nodes ─────────────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Fetch active nodes
|
|
66
|
+
* Fetch active nodes via RPC-first (LCD fallback) and check which are actually online.
|
|
67
67
|
* Returns array sorted by quality score (best first).
|
|
68
68
|
*
|
|
69
69
|
* Built-in quality scoring (from 400+ node tests):
|
|
@@ -119,12 +119,12 @@ async function _queryOnlineNodesImpl(options = {}) {
|
|
|
119
119
|
const logFn = options.log || null;
|
|
120
120
|
const brokenAddrs = new Set(BROKEN_NODES.map(n => n.address));
|
|
121
121
|
|
|
122
|
-
// 1. Fetch ALL active nodes
|
|
122
|
+
// 1. Fetch ALL active nodes via RPC-first (falls back to LCD if RPC fails)
|
|
123
123
|
let nodes = [];
|
|
124
124
|
if (options.lcdUrl) {
|
|
125
125
|
nodes = await fetchActiveNodes(options.lcdUrl);
|
|
126
126
|
} else {
|
|
127
|
-
const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, '
|
|
127
|
+
const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, 'RPC-first node list');
|
|
128
128
|
nodes = result;
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -194,12 +194,12 @@ async function _queryOnlineNodesImpl(options = {}) {
|
|
|
194
194
|
return online;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
// ─── Full Node Catalog (
|
|
197
|
+
// ─── Full Node Catalog (RPC-first, no per-node status checks) ───────────────
|
|
198
198
|
|
|
199
199
|
/**
|
|
200
|
-
* Fetch ALL active nodes
|
|
200
|
+
* Fetch ALL active nodes via RPC-first (LCD fallback). No per-node HTTP checks — instant.
|
|
201
201
|
*
|
|
202
|
-
* Returns every node that accepts udvpn, with
|
|
202
|
+
* Returns every node that accepts udvpn, with chain data:
|
|
203
203
|
* address, remote_url, gigabyte_prices, hourly_prices.
|
|
204
204
|
*
|
|
205
205
|
* Use this for: building node lists/maps, country pickers, price comparisons.
|
|
@@ -217,7 +217,7 @@ export async function fetchAllNodes(options = {}) {
|
|
|
217
217
|
const { result } = await tryWithFallback(
|
|
218
218
|
LCD_ENDPOINTS,
|
|
219
219
|
async (url) => fetchActiveNodes(url),
|
|
220
|
-
'
|
|
220
|
+
'RPC-first full node list',
|
|
221
221
|
);
|
|
222
222
|
nodes = result;
|
|
223
223
|
}
|
|
@@ -275,9 +275,9 @@ export function buildNodeIndex(nodes) {
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
/**
|
|
278
|
-
* Enrich
|
|
278
|
+
* Enrich chain nodes with type/country/city by probing each node's status API.
|
|
279
279
|
*
|
|
280
|
-
* @param {Array} nodes - Raw
|
|
280
|
+
* @param {Array} nodes - Raw chain nodes from fetchAllNodes()
|
|
281
281
|
* @param {object} [options]
|
|
282
282
|
* @param {number} [options.concurrency=30] - Parallel probes
|
|
283
283
|
* @param {function} [options.onProgress] - Callback: ({ total, done, enriched }) => void
|