blue-js-sdk 2.4.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/app-helpers.js +55 -0
- package/chain/broadcast.js +27 -0
- package/chain/fee-grants.js +271 -5
- package/chain/index.js +8 -2
- package/chain/queries.js +177 -3
- package/chain/rpc.js +117 -4
- package/cli.js +26 -5
- package/client.js +79 -7
- package/connection/connect.js +119 -21
- package/connection/disconnect.js +93 -12
- package/connection/index.js +2 -0
- 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 +68 -2
- package/docs/PRIVY-INTEGRATION.md +177 -0
- package/errors.js +167 -0
- package/index.js +75 -2
- package/node-connect.js +190 -50
- package/operator.js +26 -0
- package/package.json +11 -11
- 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/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/rpc.js
CHANGED
|
@@ -59,6 +59,57 @@ export async function createRpcQueryClientWithFallback() {
|
|
|
59
59
|
throw new Error(`All RPC endpoints failed: ${errors.map(e => `${e.url}: ${e.error}`).join('; ')}`);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// ─── Failover with Per-Attempt Timeout ────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Race a promise against a timeout. The timer is unref()'d so it won't pin
|
|
66
|
+
* the event loop if the connect succeeds first.
|
|
67
|
+
*/
|
|
68
|
+
function withTimeout(promise, ms, label) {
|
|
69
|
+
return Promise.race([
|
|
70
|
+
promise,
|
|
71
|
+
new Promise((_, rej) => {
|
|
72
|
+
const t = setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
73
|
+
if (typeof t?.unref === 'function') t.unref();
|
|
74
|
+
}),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Try RPC endpoints in order with a per-attempt timeout. Returns the first
|
|
80
|
+
* endpoint that connects AND passes a status health-check within the timeout.
|
|
81
|
+
*
|
|
82
|
+
* Fixes: Tendermint37Client.connect() never times out on a hung TCP handshake,
|
|
83
|
+
* leaving startup stalled for 30+ seconds against a Cloudflare-fronted RPC.
|
|
84
|
+
*
|
|
85
|
+
* @param {string[]} endpoints - RPC URLs to try in order
|
|
86
|
+
* @param {object} [opts]
|
|
87
|
+
* @param {number} [opts.perAttemptTimeoutMs=4000] - Timeout per endpoint (connect + status)
|
|
88
|
+
* @param {boolean} [opts.healthCheck=true] - If true, also races status() within timeout
|
|
89
|
+
* @returns {Promise<{ tmClient: Tendermint37Client, url: string }>}
|
|
90
|
+
*/
|
|
91
|
+
export async function connectFailoverWithTimeout(endpoints, { perAttemptTimeoutMs = 4000, healthCheck = true } = {}) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
for (const url of endpoints) {
|
|
94
|
+
try {
|
|
95
|
+
const tmClient = await withTimeout(
|
|
96
|
+
Tendermint37Client.connect(url),
|
|
97
|
+
perAttemptTimeoutMs,
|
|
98
|
+
`${url} connect`,
|
|
99
|
+
);
|
|
100
|
+
if (healthCheck) {
|
|
101
|
+
await withTimeout(tmClient.status(), perAttemptTimeoutMs, `${url} status`);
|
|
102
|
+
}
|
|
103
|
+
return { tmClient, url };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
errors.push({ url, msg: err.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const e = new Error(`All RPC endpoints failed (timeout: ${perAttemptTimeoutMs}ms)`);
|
|
109
|
+
e.attempts = errors;
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
/**
|
|
63
114
|
* Disconnect and clear cached RPC client.
|
|
64
115
|
*/
|
|
@@ -402,11 +453,17 @@ function decodeAllocation(fields) {
|
|
|
402
453
|
/**
|
|
403
454
|
* Query active nodes via RPC.
|
|
404
455
|
*
|
|
456
|
+
* NOTE ON PAGINATION: Sentinel v3's `QueryNodes` truncates at the requested
|
|
457
|
+
* `limit` and does NOT emit `pagination.next_key`. A standard Cosmos
|
|
458
|
+
* "loop while next_key is non-empty" pattern terminates on the first call
|
|
459
|
+
* and silently loses data. Request above the chain's current hard ceiling
|
|
460
|
+
* (~1048 active nodes as of 2026-04). Default raised to 10000.
|
|
461
|
+
*
|
|
405
462
|
* @param {{ queryClient: QueryClient }} client - From createRpcQueryClient()
|
|
406
463
|
* @param {{ status?: number, limit?: number }} [opts]
|
|
407
464
|
* @returns {Promise<Array<{ address: string, gigabyte_prices: Array, hourly_prices: Array, remote_addrs: string[], status: number }>>}
|
|
408
465
|
*/
|
|
409
|
-
export async function rpcQueryNodes(client, { status = 1, limit =
|
|
466
|
+
export async function rpcQueryNodes(client, { status = 1, limit = 10000 } = {}) {
|
|
410
467
|
const path = '/sentinel.node.v3.QueryService/QueryNodes';
|
|
411
468
|
const request = concat([
|
|
412
469
|
encodeEnum(1, status), // status field
|
|
@@ -446,12 +503,18 @@ export async function rpcQueryNode(client, address) {
|
|
|
446
503
|
/**
|
|
447
504
|
* Query nodes linked to a plan via RPC.
|
|
448
505
|
*
|
|
506
|
+
* NOTE ON PAGINATION: `QueryNodesForPlan` silently truncates at the requested
|
|
507
|
+
* `limit` with no `next_key`. Observed 2026-04: plan 36 has 803 active nodes
|
|
508
|
+
* but `limit=500` returns exactly 500 with no indication more exist. Default
|
|
509
|
+
* raised to 10000. If a plan grows beyond that, raise further — the chain's
|
|
510
|
+
* own ceiling is the effective limit.
|
|
511
|
+
*
|
|
449
512
|
* @param {{ queryClient: QueryClient }} client
|
|
450
513
|
* @param {number|bigint} planId
|
|
451
514
|
* @param {{ status?: number, limit?: number }} [opts]
|
|
452
515
|
* @returns {Promise<Array>}
|
|
453
516
|
*/
|
|
454
|
-
export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit =
|
|
517
|
+
export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit = 10000 } = {}) {
|
|
455
518
|
const path = '/sentinel.node.v3.QueryService/QueryNodesForPlan';
|
|
456
519
|
const request = concat([
|
|
457
520
|
encodeUint64(1, planId), // id
|
|
@@ -768,8 +831,14 @@ export async function rpcQueryFeeGrantsIssued(client, granter, { limit = 100 } =
|
|
|
768
831
|
const fields = decodeProto(new Uint8Array(response));
|
|
769
832
|
// Field 1 = repeated Grant
|
|
770
833
|
return (fields[1] || []).map(entry => _decodeFeeGrant(entry.value, null, granter));
|
|
771
|
-
} catch {
|
|
772
|
-
|
|
834
|
+
} catch (err) {
|
|
835
|
+
// Classify: "not found"/404 → treat as empty (genuinely no grants). Any
|
|
836
|
+
// other error (network, decode, timeout) should surface so the caller can
|
|
837
|
+
// distinguish "granter has no grants" from "RPC is broken" and fall back
|
|
838
|
+
// to LCD instead of silently returning [].
|
|
839
|
+
const msg = (err?.message || '').toLowerCase();
|
|
840
|
+
if (msg.includes('not found') || msg.includes('404')) return [];
|
|
841
|
+
throw err;
|
|
773
842
|
}
|
|
774
843
|
}
|
|
775
844
|
|
|
@@ -891,6 +960,50 @@ export async function rpcQueryProvider(client, provAddress) {
|
|
|
891
960
|
}
|
|
892
961
|
}
|
|
893
962
|
|
|
963
|
+
// ─── TX Hash Lookup ─────────────────────────────────────────────────────────
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Fetch a transaction by hash via Tendermint RPC.
|
|
967
|
+
* Accepts bare hex (64 chars) or 0x-prefixed hex. Returns a normalized shape
|
|
968
|
+
* matching the LCD cosmos/tx/v1beta1/txs/{hash} response so callers don't
|
|
969
|
+
* need to handle both formats.
|
|
970
|
+
*
|
|
971
|
+
* @param {import('@cosmjs/tendermint-rpc').Tendermint37Client} tmClient - From createRpcQueryClient().tmClient
|
|
972
|
+
* @param {string} txHash - TX hash as hex string (bare or 0x-prefixed)
|
|
973
|
+
* @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string }>}
|
|
974
|
+
* @throws {Error} If the transaction is not found or RPC call fails
|
|
975
|
+
*/
|
|
976
|
+
export async function rpcGetTxByHash(tmClient, txHash) {
|
|
977
|
+
// Strip optional 0x prefix and normalise to upper-case for consistency
|
|
978
|
+
const hex = txHash.replace(/^0x/i, '').toUpperCase();
|
|
979
|
+
// Decode hex string → Uint8Array
|
|
980
|
+
const hashBytes = new Uint8Array(hex.length / 2);
|
|
981
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
982
|
+
hashBytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const response = await tmClient.tx({ hash: hashBytes });
|
|
986
|
+
|
|
987
|
+
// Normalise events: TxData.events is already decoded by CosmJS
|
|
988
|
+
const events = (response.result.events || []).map(ev => ({
|
|
989
|
+
type: ev.type,
|
|
990
|
+
attributes: (ev.attributes || []).map(attr => ({
|
|
991
|
+
key: attr.key,
|
|
992
|
+
value: attr.value,
|
|
993
|
+
})),
|
|
994
|
+
}));
|
|
995
|
+
|
|
996
|
+
return {
|
|
997
|
+
hash: hex,
|
|
998
|
+
height: response.height,
|
|
999
|
+
code: response.result.code,
|
|
1000
|
+
rawLog: response.result.log || '',
|
|
1001
|
+
events,
|
|
1002
|
+
gasUsed: String(response.result.gasUsed),
|
|
1003
|
+
gasWanted: String(response.result.gasWanted),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
894
1007
|
export async function rpcQueryBalance(client, address, denom = 'udvpn') {
|
|
895
1008
|
const path = '/cosmos.bank.v1beta1.Query/Balance';
|
|
896
1009
|
const request = concat([
|
package/cli.js
CHANGED
|
@@ -116,7 +116,7 @@ Sentinel SDK CLI — command-line tools for Sentinel dVPN
|
|
|
116
116
|
|
|
117
117
|
WALLET
|
|
118
118
|
balance Show wallet balance
|
|
119
|
-
generate
|
|
119
|
+
generate [--out <path>] Generate new mnemonic + address (mnemonic to stderr or 0600 file)
|
|
120
120
|
address Show wallet address + provider address
|
|
121
121
|
|
|
122
122
|
NODES
|
|
@@ -177,7 +177,9 @@ Options:
|
|
|
177
177
|
--dns <preset> DNS preset: handshake (default), google, cloudflare, or custom IPs
|
|
178
178
|
|
|
179
179
|
Environment:
|
|
180
|
-
MNEMONIC BIP39 mnemonic phrase
|
|
180
|
+
MNEMONIC BIP39 mnemonic phrase. Prefer a 0600 file or OS keychain
|
|
181
|
+
over .env: .env files are world-readable to processes
|
|
182
|
+
running as the same user and are easy to commit by accident.
|
|
181
183
|
`);
|
|
182
184
|
},
|
|
183
185
|
|
|
@@ -192,9 +194,28 @@ Environment:
|
|
|
192
194
|
|
|
193
195
|
async generate() {
|
|
194
196
|
const { mnemonic, account } = await generateWallet();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
const out = flag('out', null);
|
|
198
|
+
|
|
199
|
+
if (out) {
|
|
200
|
+
const fs = await import('node:fs');
|
|
201
|
+
const path = await import('node:path');
|
|
202
|
+
const target = path.resolve(out);
|
|
203
|
+
fs.writeFileSync(target, mnemonic + '\n', { mode: 0o600 });
|
|
204
|
+
try { fs.chmodSync(target, 0o600); } catch {}
|
|
205
|
+
process.stdout.write(`Address: ${account.address}\n`);
|
|
206
|
+
process.stderr.write(`\nMnemonic written to ${target} (mode 0600).\n`);
|
|
207
|
+
process.stderr.write(`Store this file offline — anyone who reads it controls the wallet.\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Interactive path: print to stderr (not stdout) so the phrase does not
|
|
212
|
+
// land in shell pipelines, redirected log files, or CI stdout capture.
|
|
213
|
+
// Address goes to stdout because callers pipe it into automation safely.
|
|
214
|
+
process.stdout.write(`Address: ${account.address}\n`);
|
|
215
|
+
process.stderr.write(`\n*** RECOVERY PHRASE — DO NOT LOG, DO NOT COMMIT ***\n`);
|
|
216
|
+
process.stderr.write(`Mnemonic: ${mnemonic}\n`);
|
|
217
|
+
process.stderr.write(`\nStore the phrase offline (hardware wallet, paper, encrypted vault).\n`);
|
|
218
|
+
process.stderr.write(`Pass --out <path> next time to write a 0600 file instead of printing.\n`);
|
|
198
219
|
},
|
|
199
220
|
|
|
200
221
|
async address() {
|
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,
|
|
@@ -49,6 +50,10 @@ export class SentinelClient extends EventEmitter {
|
|
|
49
50
|
* @param {string} opts.rpcUrl - Default RPC URL (overridable per-call)
|
|
50
51
|
* @param {string} opts.lcdUrl - Default LCD URL (overridable per-call)
|
|
51
52
|
* @param {string} opts.mnemonic - Default mnemonic (overridable per-call)
|
|
53
|
+
* @param {object} opts.signer - Pre-built cosmjs OfflineDirectSigner (e.g. from
|
|
54
|
+
* PrivyCosmosSigner.fromRawSign). When provided, takes precedence over `mnemonic`
|
|
55
|
+
* for queries and broadcasts. NOTE: VPN connect/disconnect (tunnel handshake)
|
|
56
|
+
* currently still requires a mnemonic — see docs/PRIVY-INTEGRATION.md.
|
|
52
57
|
* @param {string} opts.v2rayExePath - Default V2Ray binary path
|
|
53
58
|
* @param {function} opts.logger - Logger function (default: console.log). Set to null to suppress.
|
|
54
59
|
* @param {'tofu'|'none'} opts.tlsTrust - TLS trust mode (default: 'tofu')
|
|
@@ -88,6 +93,21 @@ export class SentinelClient extends EventEmitter {
|
|
|
88
93
|
return merged;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Throw a helpful error if a connect path is invoked without a mnemonic.
|
|
98
|
+
* The WireGuard/V2Ray handshake currently signs locally with the cosmos privkey,
|
|
99
|
+
* so a raw-sign-only signer (e.g. Privy custody) cannot complete the tunnel
|
|
100
|
+
* handshake. Queries and broadcasts work without a mnemonic.
|
|
101
|
+
*/
|
|
102
|
+
_requireMnemonicForTunnel(merged) {
|
|
103
|
+
if (typeof merged.mnemonic === 'string' && merged.mnemonic.trim().length > 0) return;
|
|
104
|
+
throw new SentinelError(ErrorCodes.INVALID_MNEMONIC,
|
|
105
|
+
'VPN connect/disconnect requires a mnemonic. A signer-only client (e.g. ' +
|
|
106
|
+
'Privy raw-sign Mode B) can broadcast TXs and query chain state, but the ' +
|
|
107
|
+
'tunnel handshake signs with the raw secp256k1 privkey. Pass `mnemonic` to ' +
|
|
108
|
+
'the constructor or to this call. See docs/PRIVY-INTEGRATION.md.');
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
// ─── Connection ──────────────────────────────────────────────────────────
|
|
92
112
|
|
|
93
113
|
/**
|
|
@@ -97,6 +117,7 @@ export class SentinelClient extends EventEmitter {
|
|
|
97
117
|
*/
|
|
98
118
|
async connect(opts = {}) {
|
|
99
119
|
const merged = this._mergeOpts(opts);
|
|
120
|
+
this._requireMnemonicForTunnel(merged);
|
|
100
121
|
this._connection = await connectDirect(merged);
|
|
101
122
|
return this._connection;
|
|
102
123
|
}
|
|
@@ -109,6 +130,7 @@ export class SentinelClient extends EventEmitter {
|
|
|
109
130
|
*/
|
|
110
131
|
async autoConnect(opts = {}) {
|
|
111
132
|
const merged = this._mergeOpts(opts);
|
|
133
|
+
this._requireMnemonicForTunnel(merged);
|
|
112
134
|
this._connection = await connectAuto(merged);
|
|
113
135
|
return this._connection;
|
|
114
136
|
}
|
|
@@ -120,18 +142,34 @@ export class SentinelClient extends EventEmitter {
|
|
|
120
142
|
*/
|
|
121
143
|
async connectPlan(opts = {}) {
|
|
122
144
|
const merged = this._mergeOpts(opts);
|
|
145
|
+
this._requireMnemonicForTunnel(merged);
|
|
123
146
|
this._connection = await connectViaPlan(merged);
|
|
124
147
|
return this._connection;
|
|
125
148
|
}
|
|
126
149
|
|
|
127
150
|
/**
|
|
128
|
-
*
|
|
151
|
+
* Soft disconnect — tear down the tunnel, leave the on-chain session active.
|
|
152
|
+
*
|
|
153
|
+
* A subsequent connect() to the SAME node reuses the session (no new payment).
|
|
154
|
+
* Use for pause / temporary disconnect / network-change recovery.
|
|
155
|
+
* To settle the session and reclaim the deposit, use disconnectAndEndSession().
|
|
129
156
|
*/
|
|
130
157
|
async disconnect() {
|
|
131
158
|
await disconnectState(this._state);
|
|
132
159
|
this._connection = null;
|
|
133
160
|
}
|
|
134
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
|
|
164
|
+
*
|
|
165
|
+
* Settles the session after the ~2h window and refunds the unused deposit.
|
|
166
|
+
* Use when the user is done with this node (switching permanently or wants refund).
|
|
167
|
+
*/
|
|
168
|
+
async disconnectAndEndSession() {
|
|
169
|
+
await disconnectStateAndEndSession(this._state);
|
|
170
|
+
this._connection = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
135
173
|
/**
|
|
136
174
|
* Check if a VPN tunnel is currently active.
|
|
137
175
|
*/
|
|
@@ -193,16 +231,50 @@ export class SentinelClient extends EventEmitter {
|
|
|
193
231
|
// ─── Wallet & Chain ──────────────────────────────────────────────────────
|
|
194
232
|
|
|
195
233
|
/**
|
|
196
|
-
* Create or return cached wallet
|
|
197
|
-
*
|
|
234
|
+
* Create or return cached wallet/signer.
|
|
235
|
+
*
|
|
236
|
+
* Resolution order:
|
|
237
|
+
* 1. `mnemonic` arg (per-call override) → derive a DirectSecp256k1HdWallet
|
|
238
|
+
* 2. `this._defaults.signer` (constructor-supplied OfflineDirectSigner) → use as-is
|
|
239
|
+
* 3. `this._defaults.mnemonic` → derive once, cache by mnemonic SHA
|
|
240
|
+
*
|
|
241
|
+
* Returned shape: `{ wallet, account }` — `wallet` is a cosmjs OfflineDirectSigner
|
|
242
|
+
* (DirectSecp256k1HdWallet OR a PrivyRawSignDirectSigner OR any equivalent), and
|
|
243
|
+
* `account` is the first entry from `wallet.getAccounts()`.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} [mnemonic] - Optional per-call mnemonic override
|
|
198
246
|
*/
|
|
199
247
|
async getWallet(mnemonic) {
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
248
|
+
// Per-call mnemonic always wins (override path).
|
|
249
|
+
if (mnemonic) {
|
|
250
|
+
if (this._wallet && this._walletMnemonic !== mnemonic) {
|
|
251
|
+
this._wallet = null;
|
|
252
|
+
this._client = null;
|
|
253
|
+
}
|
|
254
|
+
if (this._wallet) return this._wallet;
|
|
255
|
+
this._wallet = await createWallet(mnemonic);
|
|
256
|
+
this._walletMnemonic = mnemonic;
|
|
257
|
+
return this._wallet;
|
|
258
|
+
}
|
|
259
|
+
// Constructor-supplied signer (Privy raw-sign, Keplr offline signer, etc.).
|
|
260
|
+
if (this._defaults.signer) {
|
|
261
|
+
if (this._wallet) return this._wallet;
|
|
262
|
+
const accounts = await this._defaults.signer.getAccounts();
|
|
263
|
+
if (!accounts || accounts.length === 0) {
|
|
264
|
+
throw new SentinelError(ErrorCodes.INVALID_OPTIONS,
|
|
265
|
+
'signer.getAccounts() returned no accounts');
|
|
266
|
+
}
|
|
267
|
+
this._wallet = { wallet: this._defaults.signer, account: accounts[0] };
|
|
268
|
+
this._walletMnemonic = null;
|
|
269
|
+
return this._wallet;
|
|
270
|
+
}
|
|
271
|
+
// Constructor-supplied mnemonic (the original path).
|
|
272
|
+
const m = this._defaults.mnemonic;
|
|
273
|
+
if (!m) throw new SentinelError(ErrorCodes.INVALID_MNEMONIC,
|
|
274
|
+
'No mnemonic or signer provided. Pass `mnemonic` or `signer` to the SentinelClient constructor.');
|
|
203
275
|
if (this._wallet && this._walletMnemonic !== m) {
|
|
204
276
|
this._wallet = null;
|
|
205
|
-
this._client = null;
|
|
277
|
+
this._client = null;
|
|
206
278
|
}
|
|
207
279
|
if (this._wallet) return this._wallet;
|
|
208
280
|
this._wallet = await createWallet(m);
|
package/connection/connect.js
CHANGED
|
@@ -9,13 +9,13 @@ 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 {
|
|
16
16
|
createClient, privKeyFromMnemonic, broadcastWithFeeGrant,
|
|
17
17
|
extractId, findExistingSession, getBalance, MSG_TYPES, queryNode,
|
|
18
|
-
isMnemonicValid, filterNodes,
|
|
18
|
+
isMnemonicValid, filterNodes, checkFeeGrant,
|
|
19
19
|
} from '../cosmjs-setup.js';
|
|
20
20
|
import { nodeStatusV3, waitForPort } from '../v3protocol.js';
|
|
21
21
|
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,
|
|
@@ -40,11 +40,51 @@ import {
|
|
|
40
40
|
import { performHandshake, validateTunnelRequirements, killV2RayProc, verifyDependencies } from './tunnel.js';
|
|
41
41
|
import { verifyConnection } from './state.js';
|
|
42
42
|
import { registerCleanupHandlers } from './disconnect.js';
|
|
43
|
+
import { withMnemonicRedaction } from './logger.js';
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
// Default logger wraps console.log with a mnemonic redactor. Anything that
|
|
46
|
+
// resembles a 12–24 word BIP-39 phrase in a log argument is replaced with
|
|
47
|
+
// `[REDACTED MNEMONIC]` before it reaches stdout. Defense-in-depth — the SDK
|
|
48
|
+
// does not currently log the mnemonic, but a future template-string bug or
|
|
49
|
+
// careless `JSON.stringify(opts)` won't leak it through the default logger.
|
|
50
|
+
let defaultLog = withMnemonicRedaction(console.log);
|
|
45
51
|
|
|
46
52
|
// ─── Shared Validation ───────────────────────────────────────────────────────
|
|
47
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Validate a chain endpoint URL. Enforces https:// to prevent attackers from
|
|
56
|
+
* coercing the SDK into broadcasting signed TXs over cleartext HTTP — a passive
|
|
57
|
+
* network observer on http://attacker.example/rpc would see the full TX body
|
|
58
|
+
* and could correlate it to the user's wallet address. Localhost is exempted
|
|
59
|
+
* for local-node development.
|
|
60
|
+
*
|
|
61
|
+
* @param {*} url - Value to validate (allowed: undefined/null, or string)
|
|
62
|
+
* @param {string} fieldName - 'rpcUrl' or 'lcdUrl', used in error messages
|
|
63
|
+
*/
|
|
64
|
+
function validateChainUrl(url, fieldName) {
|
|
65
|
+
if (url == null) return;
|
|
66
|
+
if (typeof url !== 'string') {
|
|
67
|
+
throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must be a string URL`, { value: url });
|
|
68
|
+
}
|
|
69
|
+
let parsed;
|
|
70
|
+
try { parsed = new URL(url); }
|
|
71
|
+
catch { throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} is not a valid URL`, { value: url }); }
|
|
72
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
73
|
+
throw new ValidationError(ErrorCodes.INVALID_URL, `${fieldName} must use http:// or https:// (got ${parsed.protocol})`, { value: url });
|
|
74
|
+
}
|
|
75
|
+
const isLocalhost = parsed.hostname === 'localhost'
|
|
76
|
+
|| parsed.hostname === '127.0.0.1'
|
|
77
|
+
|| parsed.hostname === '::1'
|
|
78
|
+
|| parsed.hostname === '[::1]';
|
|
79
|
+
if (parsed.protocol === 'http:' && !isLocalhost) {
|
|
80
|
+
throw new ValidationError(
|
|
81
|
+
ErrorCodes.INVALID_URL,
|
|
82
|
+
`${fieldName} must use https:// for non-localhost endpoints. Cleartext HTTP leaks signed TXs and queries to network observers (got ${url}).`,
|
|
83
|
+
{ value: url },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
48
88
|
function validateConnectOpts(opts, fnName) {
|
|
49
89
|
if (!opts || typeof opts !== 'object') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, `${fnName}() requires an options object`);
|
|
50
90
|
if (typeof opts.mnemonic !== 'string') {
|
|
@@ -60,8 +100,8 @@ function validateConnectOpts(opts, fnName) {
|
|
|
60
100
|
if (typeof opts.nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(opts.nodeAddress)) {
|
|
61
101
|
throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (47 characters)', { value: opts.nodeAddress });
|
|
62
102
|
}
|
|
63
|
-
|
|
64
|
-
|
|
103
|
+
validateChainUrl(opts.rpcUrl, 'rpcUrl');
|
|
104
|
+
validateChainUrl(opts.lcdUrl, 'lcdUrl');
|
|
65
105
|
}
|
|
66
106
|
|
|
67
107
|
// ─── Shared Connect Flow (eliminates connectDirect/connectViaPlan duplication) ─
|
|
@@ -79,8 +119,10 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
|
|
|
79
119
|
{ nodeAddress: state.connection?.nodeAddress });
|
|
80
120
|
}
|
|
81
121
|
const prev = state.connection;
|
|
82
|
-
|
|
83
|
-
|
|
122
|
+
// Hard disconnect: user is actively connecting to a different node,
|
|
123
|
+
// so the old session should be settled and the deposit refunded.
|
|
124
|
+
await disconnectStateAndEndSession(state);
|
|
125
|
+
if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Ended session on ${prev?.nodeAddress || 'previous node'} — connecting to new node`);
|
|
84
126
|
}
|
|
85
127
|
|
|
86
128
|
const onProgress = opts.onProgress || null;
|
|
@@ -97,13 +139,17 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
|
|
|
97
139
|
// v21: parallelized — saves ~300ms (was sequential)
|
|
98
140
|
progress(onProgress, logFn, 'wallet', 'Setting up wallet...');
|
|
99
141
|
checkAborted(signal);
|
|
100
|
-
const [
|
|
142
|
+
const [walletResult, privKey] = await Promise.all([
|
|
101
143
|
cachedCreateWallet(opts.mnemonic),
|
|
102
144
|
privKeyFromMnemonic(opts.mnemonic),
|
|
103
145
|
]);
|
|
146
|
+
const { wallet, account } = walletResult;
|
|
104
147
|
|
|
105
|
-
//
|
|
106
|
-
|
|
148
|
+
// v37 (security): store the derived OfflineSigner — NOT the BIP-39 mnemonic.
|
|
149
|
+
// _endSessionOnChain only needs to sign one TX on disconnect; it doesn't need
|
|
150
|
+
// the recovery phrase. Holding the mnemonic in heap for the full session
|
|
151
|
+
// expanded the heap-dump attack surface unnecessarily.
|
|
152
|
+
state._wallet = walletResult;
|
|
107
153
|
|
|
108
154
|
// 2. RPC connect + LCD lookup in parallel (independent network calls)
|
|
109
155
|
// v21: parallelized — saves 1-3s (was sequential)
|
|
@@ -379,9 +425,12 @@ export async function connectDirect(opts) {
|
|
|
379
425
|
|
|
380
426
|
// ── Fast Reconnect: check for saved credentials ──
|
|
381
427
|
if (!forceNewSession) {
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
428
|
+
// v37: Derive wallet BEFORE fast reconnect and stash on state — _endSessionOnChain()
|
|
429
|
+
// needs the signer, not the mnemonic. Same wallet object is reused by connectInternal()
|
|
430
|
+
// below if fast reconnect misses (cachedCreateWallet keys on mnemonic SHA256).
|
|
431
|
+
const fastState = opts._state || _defaultState;
|
|
432
|
+
fastState._wallet = await cachedCreateWallet(opts.mnemonic);
|
|
433
|
+
const fast = await tryFastReconnect(opts, fastState);
|
|
385
434
|
if (fast) {
|
|
386
435
|
clearCircuitBreaker(opts.nodeAddress);
|
|
387
436
|
return fast;
|
|
@@ -397,7 +446,20 @@ export async function connectDirect(opts) {
|
|
|
397
446
|
if (!forceNewSession) {
|
|
398
447
|
progress(onProgress, logFn, 'session', 'Checking for existing session...');
|
|
399
448
|
checkAborted(signal);
|
|
400
|
-
sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress
|
|
449
|
+
sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress, {
|
|
450
|
+
// Dedup: if multiple active sessions exist for this node (stale duplicates from
|
|
451
|
+
// crashes or multi-client wallets), keep the highest-ID one. Silently cancel stale
|
|
452
|
+
// lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
|
|
453
|
+
onStaleDuplicate: (staleId) => {
|
|
454
|
+
logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
|
|
455
|
+
// v37: pass the derived wallet (set on state above) instead of the mnemonic
|
|
456
|
+
const w = (opts._state || _defaultState)._wallet;
|
|
457
|
+
if (!w) { logFn?.(`[connect] No wallet on state — skipping stale session ${staleId} cleanup`); return; }
|
|
458
|
+
_endSessionOnChain(staleId, w).catch(e => {
|
|
459
|
+
logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
});
|
|
401
463
|
if (sessionId && isSessionPoisoned(String(sessionId))) {
|
|
402
464
|
progress(onProgress, logFn, 'session', `Session ${sessionId} previously failed — skipping`);
|
|
403
465
|
sessionId = null;
|
|
@@ -536,7 +598,7 @@ export async function connectAuto(opts) {
|
|
|
536
598
|
if (opts.circuitBreaker) configureCircuitBreaker(opts.circuitBreaker);
|
|
537
599
|
|
|
538
600
|
const maxAttempts = opts.maxAttempts || 3;
|
|
539
|
-
const logFn = opts.log ||
|
|
601
|
+
const logFn = opts.log || defaultLog;
|
|
540
602
|
const errors = [];
|
|
541
603
|
|
|
542
604
|
// If nodeAddress specified, try it first (skip circuit breaker check for explicit choice)
|
|
@@ -689,6 +751,22 @@ export async function connectViaPlan(opts) {
|
|
|
689
751
|
// Fee grant: the app passes the plan owner's address as feeGranter.
|
|
690
752
|
const feeGranter = opts.feeGranter || null;
|
|
691
753
|
|
|
754
|
+
// Opt-in precheck: verify the grant exists and is not expired before the TX.
|
|
755
|
+
// Builders who prefer fail-fast over silent user-pay fallback set requireFeeGrant.
|
|
756
|
+
if (feeGranter && opts.requireFeeGrant === true) {
|
|
757
|
+
const status = await checkFeeGrant(lcdUrl, feeGranter, account.address);
|
|
758
|
+
if (!status.exists) {
|
|
759
|
+
throw new ChainError(ErrorCodes.FEE_GRANT_MISSING_AT_START,
|
|
760
|
+
`Required fee grant missing from ${feeGranter} to ${account.address}`,
|
|
761
|
+
{ granter: feeGranter, grantee: account.address });
|
|
762
|
+
}
|
|
763
|
+
if (status.expired) {
|
|
764
|
+
throw new ChainError(ErrorCodes.FEE_GRANT_EXPIRED,
|
|
765
|
+
`Required fee grant expired at ${status.expiresAt?.toISOString()}`,
|
|
766
|
+
{ granter: feeGranter, grantee: account.address, expiresAt: status.expiresAt });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
692
770
|
progress(null, opts.log || defaultLog, 'session', `Subscribing to plan ${opts.planId} + starting session${feeGranter ? ' (fee granted)' : ''}...`);
|
|
693
771
|
|
|
694
772
|
let result;
|
|
@@ -696,8 +774,10 @@ export async function connectViaPlan(opts) {
|
|
|
696
774
|
try {
|
|
697
775
|
result = await broadcastWithFeeGrant(client, account.address, [msg], feeGranter);
|
|
698
776
|
} catch (feeErr) {
|
|
699
|
-
|
|
700
|
-
|
|
777
|
+
if (opts.requireFeeGrant === true) throw feeErr;
|
|
778
|
+
// Fee grant TX failed — fall back to user-paid (default behavior)
|
|
779
|
+
const reason = feeErr?.message ? `: ${feeErr.message}` : '';
|
|
780
|
+
progress(null, opts.log || defaultLog, 'session', `Fee grant failed${reason}, paying gas from wallet...`);
|
|
701
781
|
result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
|
|
702
782
|
}
|
|
703
783
|
} else {
|
|
@@ -758,7 +838,7 @@ export async function connectViaSubscription(opts) {
|
|
|
758
838
|
try {
|
|
759
839
|
|
|
760
840
|
async function subPayment(ctx) {
|
|
761
|
-
const { client, account, logFn, onProgress, signal } = ctx;
|
|
841
|
+
const { client, account, lcd: lcdUrl, logFn, onProgress, signal } = ctx;
|
|
762
842
|
const msg = {
|
|
763
843
|
typeUrl: MSG_TYPES.SUB_START_SESSION,
|
|
764
844
|
value: {
|
|
@@ -772,6 +852,22 @@ export async function connectViaSubscription(opts) {
|
|
|
772
852
|
|
|
773
853
|
// Fee grant: operator pays gas for the agent (e.g., x402 managed plan flow)
|
|
774
854
|
const feeGranter = opts.feeGranter || null;
|
|
855
|
+
|
|
856
|
+
// Opt-in precheck: verify the grant exists and is not expired before the TX.
|
|
857
|
+
if (feeGranter && opts.requireFeeGrant === true) {
|
|
858
|
+
const status = await checkFeeGrant(lcdUrl, feeGranter, account.address);
|
|
859
|
+
if (!status.exists) {
|
|
860
|
+
throw new ChainError(ErrorCodes.FEE_GRANT_MISSING_AT_START,
|
|
861
|
+
`Required fee grant missing from ${feeGranter} to ${account.address}`,
|
|
862
|
+
{ granter: feeGranter, grantee: account.address });
|
|
863
|
+
}
|
|
864
|
+
if (status.expired) {
|
|
865
|
+
throw new ChainError(ErrorCodes.FEE_GRANT_EXPIRED,
|
|
866
|
+
`Required fee grant expired at ${status.expiresAt?.toISOString()}`,
|
|
867
|
+
{ granter: feeGranter, grantee: account.address, expiresAt: status.expiresAt });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
775
871
|
progress(null, opts.log || defaultLog, 'session', `Starting session via subscription ${opts.subscriptionId}${feeGranter ? ' (fee granted)' : ''}...`);
|
|
776
872
|
|
|
777
873
|
let result;
|
|
@@ -779,8 +875,10 @@ export async function connectViaSubscription(opts) {
|
|
|
779
875
|
try {
|
|
780
876
|
result = await broadcastWithFeeGrant(client, account.address, [msg], feeGranter);
|
|
781
877
|
} catch (feeErr) {
|
|
782
|
-
|
|
783
|
-
|
|
878
|
+
if (opts.requireFeeGrant === true) throw feeErr;
|
|
879
|
+
// Fee grant TX failed — fall back to user-paid (default behavior)
|
|
880
|
+
const reason = feeErr?.message ? `: ${feeErr.message}` : '';
|
|
881
|
+
progress(null, opts.log || defaultLog, 'session', `Fee grant failed${reason}, paying gas from wallet...`);
|
|
784
882
|
result = await broadcastWithInactiveRetry(client, account.address, [msg], opts.log || defaultLog, opts.onProgress);
|
|
785
883
|
}
|
|
786
884
|
} else {
|