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 CHANGED
@@ -72,6 +72,15 @@ export const COUNTRY_MAP = Object.freeze({
72
72
  'pr': 'PR', 'cn': 'CN', 'sa': 'SA', 'kz': 'KZ', 'mn': 'MN', 'sk': 'SK',
73
73
  'al': 'AL', 'md': 'MD', 'jm': 'JM', 'bo': 'BO', 'ec': 'EC', 'uy': 'UY',
74
74
  'bh': 'BH', 'cd': 'CD',
75
+
76
+ // Central Asia
77
+ 'kyrgyzstan': 'KG', 'uzbekistan': 'UZ', 'tajikistan': 'TJ',
78
+ 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
79
+
80
+ // Balkans
81
+ 'bosnia and herzegovina': 'BA', 'north macedonia': 'MK', 'montenegro': 'ME',
82
+ 'kosovo': 'XK', 'slovenia': 'SI',
83
+ 'ba': 'BA', 'mk': 'MK', 'me': 'ME', 'xk': 'XK', 'si': 'SI',
75
84
  });
76
85
 
77
86
  /**
@@ -286,6 +295,52 @@ export function groupNodesByCountry(nodes) {
286
295
  });
287
296
  }
288
297
 
298
+ // ─── Country → Continent Map ────────────────────────────────────────────────
299
+ // Continent classification for every ISO code present in COUNTRY_MAP.
300
+ // Codes follow ISO 3166-1 alpha-2 regions: EU/AS/NA/SA/AF/OC (+ AN, ZZ).
301
+
302
+ export const CONTINENT_BY_CODE = Object.freeze({
303
+ // Europe
304
+ DE: 'EU', FR: 'EU', GB: 'EU', NL: 'EU', ES: 'EU', IT: 'EU', SE: 'EU', NO: 'EU',
305
+ FI: 'EU', CH: 'EU', AT: 'EU', IE: 'EU', PT: 'EU', CZ: 'EU', HU: 'EU', BG: 'EU',
306
+ GR: 'EU', UA: 'EU', RU: 'EU', RO: 'EU', PL: 'EU', TR: 'EU', LV: 'EU', LT: 'EU',
307
+ EE: 'EU', HR: 'EU', RS: 'EU', DK: 'EU', BE: 'EU', LU: 'EU', MT: 'EU', CY: 'EU',
308
+ IS: 'EU', SK: 'EU', AL: 'EU', MD: 'EU', BA: 'EU', MK: 'EU', ME: 'EU', XK: 'EU',
309
+ SI: 'EU', GE: 'EU',
310
+ // Asia
311
+ JP: 'AS', SG: 'AS', IN: 'AS', KR: 'AS', HK: 'AS', TW: 'AS', TH: 'AS', VN: 'AS',
312
+ ID: 'AS', PH: 'AS', MY: 'AS', BD: 'AS', PK: 'AS', CN: 'AS', SA: 'AS', KZ: 'AS',
313
+ MN: 'AS', IL: 'AS', AE: 'AS', KG: 'AS', UZ: 'AS', TJ: 'AS', BH: 'AS',
314
+ // North America
315
+ US: 'NA', CA: 'NA', MX: 'NA', GT: 'NA', PR: 'NA', JM: 'NA', CR: 'NA', PA: 'NA',
316
+ DO: 'NA', SV: 'NA', HN: 'NA', NI: 'NA', CU: 'NA', HT: 'NA', TT: 'NA',
317
+ // South America
318
+ BR: 'SA', AR: 'SA', CL: 'SA', CO: 'SA', PE: 'SA', VE: 'SA', BO: 'SA', EC: 'SA',
319
+ UY: 'SA', PY: 'SA',
320
+ // Africa
321
+ ZA: 'AF', NG: 'AF', EG: 'AF', KE: 'AF', MA: 'AF', CD: 'AF',
322
+ // Oceania
323
+ AU: 'OC', NZ: 'OC',
324
+ });
325
+
326
+ export const CONTINENT_NAMES = Object.freeze({
327
+ EU: 'Europe', AS: 'Asia', NA: 'North America', SA: 'South America',
328
+ AF: 'Africa', OC: 'Oceania', AN: 'Antarctica', ZZ: 'Unknown',
329
+ });
330
+
331
+ /**
332
+ * Map a country (name or ISO code) to a continent code.
333
+ *
334
+ * @param {string} country - Country name (any variant in COUNTRY_MAP) or 2-letter ISO code
335
+ * @returns {string|null} 'EU' | 'AS' | 'NA' | 'SA' | 'AF' | 'OC' | null
336
+ */
337
+ export function countryToContinent(country) {
338
+ if (!country) return null;
339
+ const code = country.length === 2 ? country.toUpperCase() : countryNameToCode(country);
340
+ if (!code) return null;
341
+ return CONTINENT_BY_CODE[code] || null;
342
+ }
343
+
289
344
  // ─── Session Duration Helpers ───────────────────────────────────────────────
290
345
 
291
346
  /** Common hour options for hourly session selection UI. */
@@ -142,6 +142,33 @@ export function createSafeBroadcaster(rpcUrl, wallet, signerAddress) {
142
142
  return { safeBroadcast, getClient, resetClient };
143
143
  }
144
144
 
145
+ // ─── Shared Broadcast Pool (per-key serialization) ──────────────────────────
146
+ // For servers handling multiple concurrent wallets. Each wallet address gets
147
+ // its own queue so wallet A's TX never blocks wallet B. Single-wallet
148
+ // consumers (CLI tools) can pass any string as key — behavior is identical
149
+ // to a single-instance createSafeBroadcaster.
150
+
151
+ const _globalQueues = new Map();
152
+
153
+ /**
154
+ * Serialize a broadcast behind a per-key queue without creating a full
155
+ * createSafeBroadcaster instance. Useful when the caller already has a
156
+ * signing client and only needs sequence-safe serialization per wallet.
157
+ *
158
+ * @param {string} key - Wallet address (or any unique string per logical sender)
159
+ * @param {() => Promise<any>} fn - Async function to serialize
160
+ * @returns {Promise<any>}
161
+ */
162
+ export function withBroadcastQueue(key, fn) {
163
+ const prev = _globalQueues.get(key) ?? Promise.resolve();
164
+ const p = prev.then(fn);
165
+ _globalQueues.set(key, p.catch(() => {}));
166
+ p.finally(() => {
167
+ if (_globalQueues.get(key) === p) _globalQueues.delete(key);
168
+ });
169
+ return p;
170
+ }
171
+
145
172
  /**
146
173
  * Broadcast a TX with fee paid by a granter (fee grant).
147
174
  * The grantee signs; the granter pays gas via their fee allowance.
@@ -185,6 +185,62 @@ export async function queryFeeGrant(lcdUrl, granter, grantee) {
185
185
  } catch { return null; } // 404 = no grant
186
186
  }
187
187
 
188
+ /**
189
+ * Builder-friendly fee-grant check. Returns a parsed, normalized status so
190
+ * callers don't have to walk nested `AllowedMsgAllowance` → `BasicAllowance`
191
+ * shapes. Use this before broadcasting plan/subscription sessions that rely on
192
+ * a granter paying gas.
193
+ *
194
+ * RPC-first with LCD fallback. Either both granter+grantee, or pre-fetched
195
+ * allowance object can be passed.
196
+ *
197
+ * @param {string} lcdUrl - LCD endpoint (for fallback)
198
+ * @param {string} granter - sent1... granter address
199
+ * @param {string} grantee - sent1... grantee address
200
+ * @returns {Promise<{ exists: boolean, expired: boolean, expiresAt: Date|null, spendLimit: Array<{denom:string,amount:string}>, allowedMessages: string[]|null, typeUrl: string, raw: object|null }>}
201
+ */
202
+ export async function checkFeeGrant(lcdUrl, granter, grantee) {
203
+ const allowance = await queryFeeGrant(lcdUrl, granter, grantee);
204
+ if (!allowance) {
205
+ return {
206
+ exists: false,
207
+ expired: false,
208
+ expiresAt: null,
209
+ spendLimit: [],
210
+ allowedMessages: null,
211
+ typeUrl: '',
212
+ raw: null,
213
+ };
214
+ }
215
+
216
+ // If shape is { granter, grantee, allowance: {...} } from rpcQueryFeeGrant
217
+ const inner = allowance.allowance || allowance;
218
+ const typeUrl = inner['@type'] || '';
219
+ let basic = inner;
220
+ let allowedMessages = null;
221
+ if (typeUrl.includes('AllowedMsgAllowance')) {
222
+ allowedMessages = inner.allowed_messages || [];
223
+ basic = inner.allowance || null;
224
+ }
225
+
226
+ const spendLimit = (basic?.spend_limit || []).map(c => ({
227
+ denom: c.denom,
228
+ amount: String(c.amount || '0'),
229
+ }));
230
+ const expiresAt = basic?.expiration ? new Date(basic.expiration) : null;
231
+ const expired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
232
+
233
+ return {
234
+ exists: true,
235
+ expired,
236
+ expiresAt,
237
+ spendLimit,
238
+ allowedMessages,
239
+ typeUrl,
240
+ raw: allowance,
241
+ };
242
+ }
243
+
188
244
  // ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
189
245
 
190
246
  /**
@@ -194,19 +250,26 @@ export async function queryFeeGrant(lcdUrl, granter, grantee) {
194
250
  * @param {number|string} planId
195
251
  * @param {object} opts
196
252
  * @param {string} opts.granterAddress - Who pays fees (typically plan owner)
197
- * @param {string} opts.lcdUrl - LCD endpoint
253
+ * @param {string} [opts.lcdUrl] - LCD endpoint (used as fallback when RPC unavailable)
254
+ * @param {boolean} [opts.preferRpc=true] - Force RPC for all queries, skip LCD URL for grant lookup
255
+ * @param {object} [opts.rpcClient] - Pre-built RPC client to inject (skips internal createRpcQueryClientWithFallback)
198
256
  * @param {object} [opts.grantOpts] - Options for buildFeeGrantMsg (spendLimit, expiration, allowedMessages)
199
257
  * @returns {Promise<{ msgs: Array, skipped: string[], newGrants: string[] }>} Messages ready for broadcast
200
258
  */
201
259
  export async function grantPlanSubscribers(planId, opts = {}) {
202
- const { granterAddress, lcdUrl, grantOpts = {} } = opts;
260
+ const { granterAddress, lcdUrl, preferRpc = true, rpcClient, grantOpts = {} } = opts;
203
261
  if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
204
262
 
205
- // Get subscribers
263
+ // Get subscribers — already RPC-first internally
206
264
  const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
207
265
 
208
- // Get existing grants ISSUED BY granter (not grants received)
209
- const existingGrants = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
266
+ // Get existing grants ISSUED BY granter.
267
+ // preferRpc=true: pass null lcdUrl so internal getRpcClient() path is taken first.
268
+ // preferRpc=false: pass lcdUrl so LCD is used (useful when RPC is blocked).
269
+ const grantLcdUrl = preferRpc ? null : (lcdUrl || LCD_ENDPOINTS[0].url);
270
+ const existingGrants = rpcClient
271
+ ? await _rpcQueryFeeGrantsIssued(rpcClient, granterAddress).catch(() => queryFeeGrantsIssued(grantLcdUrl, granterAddress))
272
+ : await queryFeeGrantsIssued(grantLcdUrl, granterAddress);
210
273
  const alreadyGranted = new Set(existingGrants.map(g => g.grantee));
211
274
 
212
275
  const msgs = [];
@@ -375,7 +438,9 @@ export function monitorFeeGrants(opts = {}) {
375
438
  * @param {number|string} planId
376
439
  * @param {object} opts
377
440
  * @param {string} opts.granterAddress - Plan owner paying fees
378
- * @param {string} opts.lcdUrl - LCD endpoint
441
+ * @param {string} [opts.lcdUrl] - LCD endpoint (used as fallback when RPC unavailable)
442
+ * @param {boolean} [opts.preferRpc=true] - Force RPC for all queries, skip LCD URL for grant lookup
443
+ * @param {object} [opts.rpcClient] - Pre-built RPC client to inject (skips internal createRpcQueryClientWithFallback)
379
444
  * @param {(msgs: Array, memo: string) => Promise<{code:number, rawLog?:string, transactionHash?:string}>} opts.broadcast
380
445
  * @param {object} [opts.grantOpts] - { spendLimit, expiration } for BasicAllowance
381
446
  * @param {number} [opts.batchSize=5] - Msgs per TX
@@ -386,6 +451,8 @@ export async function* streamGrantPlanSubscribers(planId, opts = {}) {
386
451
  const {
387
452
  granterAddress,
388
453
  lcdUrl,
454
+ preferRpc = true,
455
+ rpcClient,
389
456
  broadcast,
390
457
  grantOpts = {},
391
458
  batchSize = 5,
@@ -416,7 +483,10 @@ export async function* streamGrantPlanSubscribers(planId, opts = {}) {
416
483
  }
417
484
 
418
485
  yield { type: 'status', msg: 'Checking existing grants...' };
419
- const existing = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
486
+ const streamLcdUrl = preferRpc ? null : (lcdUrl || LCD_ENDPOINTS[0].url);
487
+ const existing = rpcClient
488
+ ? await _rpcQueryFeeGrantsIssued(rpcClient, granterAddress).catch(() => queryFeeGrantsIssued(streamLcdUrl, granterAddress))
489
+ : await queryFeeGrantsIssued(streamLcdUrl, granterAddress);
420
490
  const existingGrantees = new Set(existing.map(g => g.grantee));
421
491
  const needGrant = uniqueAddrs.filter(a => !existingGrantees.has(a));
422
492
  const skipped = uniqueAddrs.length - needGrant.length;
package/chain/queries.js CHANGED
@@ -618,8 +618,80 @@ export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
618
618
  } catch { return []; }
619
619
  }
620
620
 
621
+ /**
622
+ * Normalize chain status values from RPC (numeric: 1=active, 2=inactive, 3=pending)
623
+ * and LCD (strings: "STATUS_ACTIVE", "STATUS_INACTIVE", "STATUS_INACTIVE_PENDING").
624
+ * Returns true only for the ACTIVE status, never for INACTIVE_PENDING (status=3),
625
+ * which is a transient terminal state and should never be treated as connectable.
626
+ *
627
+ * @param {number|string|undefined} v
628
+ * @returns {boolean}
629
+ */
630
+ export function isActiveStatus(v) {
631
+ if (v === 1 || v === '1') return true;
632
+ if (typeof v === 'string') return v === 'STATUS_ACTIVE' || v === 'active';
633
+ return false;
634
+ }
635
+
621
636
  // ─── Plan Subscriber Helpers (v25b) ──────────────────────────────────────────
622
637
 
638
+ /**
639
+ * Query a single plan's metadata. RPC-first with LCD fallback.
640
+ *
641
+ * Returns the plan's provider (sentprov1...), price list, duration, bytes quota,
642
+ * and status. Builders use this before connecting through a plan subscription to
643
+ * resolve the plan owner (the address that typically acts as fee granter for
644
+ * MsgStartSubscriptionRequest + MsgPlanStartSession TXs).
645
+ *
646
+ * NOTE: `prov_address` is a sentprov1... provider address, which is usually
647
+ * derived from a sent1... account address. If you need the operator's sent1...
648
+ * account (the expected fee granter), either pass it in explicitly via app
649
+ * config or call `getProviderByAddress(prov_address)` and read `.address`.
650
+ *
651
+ * @param {number|string} planId
652
+ * @param {object} [opts]
653
+ * @param {string} [opts.lcdUrl]
654
+ * @returns {Promise<{ planId: string, provider: string, prices: Array, bytes: string, duration: string, status: number, statusAt: string|null, private: boolean } | null>}
655
+ */
656
+ export async function queryPlanDetails(planId, opts = {}) {
657
+ // RPC-first
658
+ try {
659
+ const rpc = await getRpcClient();
660
+ if (rpc) {
661
+ const plan = await rpcQueryPlan(rpc, planId);
662
+ if (plan) {
663
+ return {
664
+ planId: String(plan.id),
665
+ provider: plan.prov_address,
666
+ prices: plan.prices || [],
667
+ bytes: plan.bytes || '0',
668
+ duration: plan.duration || '0s',
669
+ status: plan.status,
670
+ statusAt: plan.status_at,
671
+ private: plan.private === true,
672
+ };
673
+ }
674
+ }
675
+ } catch { /* fall through to LCD */ }
676
+
677
+ // LCD fallback: /sentinel/plan/v3/plans/{planId}
678
+ try {
679
+ const data = await lcdQuery(`/sentinel/plan/v3/plans/${planId}`, { lcdUrl: opts.lcdUrl });
680
+ const plan = data?.plan;
681
+ if (!plan) return null;
682
+ return {
683
+ planId: String(plan.id),
684
+ provider: plan.prov_address || plan.provider_address || '',
685
+ prices: plan.prices || [],
686
+ bytes: plan.bytes || '0',
687
+ duration: plan.duration || '0s',
688
+ status: typeof plan.status === 'number' ? plan.status : (plan.status === 'STATUS_ACTIVE' ? 1 : 2),
689
+ statusAt: plan.status_at || null,
690
+ private: plan.private === true,
691
+ };
692
+ } catch { return null; }
693
+ }
694
+
623
695
  /**
624
696
  * Query all subscriptions for a plan. Supports owner filtering.
625
697
  * RPC-first with LCD fallback.
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
  */
@@ -780,8 +831,14 @@ export async function rpcQueryFeeGrantsIssued(client, granter, { limit = 100 } =
780
831
  const fields = decodeProto(new Uint8Array(response));
781
832
  // Field 1 = repeated Grant
782
833
  return (fields[1] || []).map(entry => _decodeFeeGrant(entry.value, null, granter));
783
- } catch {
784
- return [];
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;
785
842
  }
786
843
  }
787
844
 
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 Generate new mnemonic + address
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 (in .env file)
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
- console.log(`Mnemonic: ${mnemonic}`);
196
- console.log(`Address: ${account.address}`);
197
- console.log(`\nSave the mnemonic in your .env file as MNEMONIC="..."`);
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
@@ -50,6 +50,10 @@ export class SentinelClient extends EventEmitter {
50
50
  * @param {string} opts.rpcUrl - Default RPC URL (overridable per-call)
51
51
  * @param {string} opts.lcdUrl - Default LCD URL (overridable per-call)
52
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.
53
57
  * @param {string} opts.v2rayExePath - Default V2Ray binary path
54
58
  * @param {function} opts.logger - Logger function (default: console.log). Set to null to suppress.
55
59
  * @param {'tofu'|'none'} opts.tlsTrust - TLS trust mode (default: 'tofu')
@@ -89,6 +93,21 @@ export class SentinelClient extends EventEmitter {
89
93
  return merged;
90
94
  }
91
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
+
92
111
  // ─── Connection ──────────────────────────────────────────────────────────
93
112
 
94
113
  /**
@@ -98,6 +117,7 @@ export class SentinelClient extends EventEmitter {
98
117
  */
99
118
  async connect(opts = {}) {
100
119
  const merged = this._mergeOpts(opts);
120
+ this._requireMnemonicForTunnel(merged);
101
121
  this._connection = await connectDirect(merged);
102
122
  return this._connection;
103
123
  }
@@ -110,6 +130,7 @@ export class SentinelClient extends EventEmitter {
110
130
  */
111
131
  async autoConnect(opts = {}) {
112
132
  const merged = this._mergeOpts(opts);
133
+ this._requireMnemonicForTunnel(merged);
113
134
  this._connection = await connectAuto(merged);
114
135
  return this._connection;
115
136
  }
@@ -121,6 +142,7 @@ export class SentinelClient extends EventEmitter {
121
142
  */
122
143
  async connectPlan(opts = {}) {
123
144
  const merged = this._mergeOpts(opts);
145
+ this._requireMnemonicForTunnel(merged);
124
146
  this._connection = await connectViaPlan(merged);
125
147
  return this._connection;
126
148
  }
@@ -209,16 +231,50 @@ export class SentinelClient extends EventEmitter {
209
231
  // ─── Wallet & Chain ──────────────────────────────────────────────────────
210
232
 
211
233
  /**
212
- * Create or return cached wallet from mnemonic.
213
- * @param {string} mnemonic - Override mnemonic (or uses instance default)
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
214
246
  */
215
247
  async getWallet(mnemonic) {
216
- const m = mnemonic || this._defaults.mnemonic;
217
- if (!m) throw new SentinelError(ErrorCodes.INVALID_MNEMONIC, 'No mnemonic provided');
218
- // Invalidate cache if mnemonic changed
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.');
219
275
  if (this._wallet && this._walletMnemonic !== m) {
220
276
  this._wallet = null;
221
- this._client = null; // client depends on wallet
277
+ this._client = null;
222
278
  }
223
279
  if (this._wallet) return this._wallet;
224
280
  this._wallet = await createWallet(m);