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/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. */
|
package/chain/broadcast.js
CHANGED
|
@@ -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.
|
package/chain/fee-grants.js
CHANGED
|
@@ -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
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
213
|
-
*
|
|
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
|
-
|
|
217
|
-
if (
|
|
218
|
-
|
|
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;
|
|
277
|
+
this._client = null;
|
|
222
278
|
}
|
|
223
279
|
if (this._wallet) return this._wallet;
|
|
224
280
|
this._wallet = await createWallet(m);
|