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/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
JavaScript/TypeScript SDK for the [Sentinel](https://sentinel.co) decentralized VPN network. WireGuard + V2Ray tunnels, Cosmos blockchain, 900+ nodes. RPC queries, typed events, CosmJS compatible.
|
|
4
4
|
|
|
5
|
-
**Also available:** [Blue C# SDK](https://github.com/Sentinel-Autonomybuilder/blue-csharp-sdk) | [Blue
|
|
5
|
+
**Also available:** [Blue C# SDK](https://github.com/Sentinel-Autonomybuilder/blue-csharp-sdk) | [Blue Agent Connect](https://github.com/Sentinel-Autonomybuilder/blue-agent-connect) (zero-config wrapper for AI agents)
|
|
6
6
|
|
|
7
7
|
## Platform Support
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ JavaScript/TypeScript SDK for the [Sentinel](https://sentinel.co) decentralized
|
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
> **For AI agents:** If you just want `connect()` with one function call, use [`blue-
|
|
21
|
+
> **For AI agents:** If you just want `connect()` with one function call, use [`blue-agent-connect`](https://www.npmjs.com/package/blue-agent-connect) instead.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -46,7 +46,7 @@ await disconnect();
|
|
|
46
46
|
|
|
47
47
|
## For AI Agents
|
|
48
48
|
|
|
49
|
-
Use [
|
|
49
|
+
Use [blue-agent-connect](https://www.npmjs.com/package/blue-agent-connect) — a zero-config wrapper with one function call from zero to encrypted tunnel.
|
|
50
50
|
|
|
51
51
|
## Features
|
|
52
52
|
|
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 = [];
|
|
@@ -354,3 +417,206 @@ export function monitorFeeGrants(opts = {}) {
|
|
|
354
417
|
|
|
355
418
|
return emitter;
|
|
356
419
|
}
|
|
420
|
+
|
|
421
|
+
// ─── Streaming Batch Grant (for SSE / progress UIs) ──────────────────────────
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Stream progress as we grant fee allowances to all plan subscribers in batches.
|
|
425
|
+
*
|
|
426
|
+
* Async generator. Yields events with `{ type, ...payload }`:
|
|
427
|
+
* - status { msg } — human-readable status line
|
|
428
|
+
* - batch_start { batch, total, count, addresses } — about to broadcast a batch
|
|
429
|
+
* - batch_ok { batch, total, granted, totalGranted, txHash, elapsed }
|
|
430
|
+
* - batch_error { batch, total, error, elapsed }
|
|
431
|
+
* - done { granted, skipped, total, errors? }
|
|
432
|
+
* - error { msg }
|
|
433
|
+
*
|
|
434
|
+
* The caller passes a `broadcast(msgs, memo)` function — any safe-broadcaster
|
|
435
|
+
* with the Plan Manager's mutex + sequence-retry semantics works. Consumer
|
|
436
|
+
* routes layer SSE (`res.write('data: ...\n\n')`) on top of these events.
|
|
437
|
+
*
|
|
438
|
+
* @param {number|string} planId
|
|
439
|
+
* @param {object} opts
|
|
440
|
+
* @param {string} opts.granterAddress - Plan owner paying fees
|
|
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)
|
|
444
|
+
* @param {(msgs: Array, memo: string) => Promise<{code:number, rawLog?:string, transactionHash?:string}>} opts.broadcast
|
|
445
|
+
* @param {object} [opts.grantOpts] - { spendLimit, expiration } for BasicAllowance
|
|
446
|
+
* @param {number} [opts.batchSize=5] - Msgs per TX
|
|
447
|
+
* @param {() => boolean} [opts.isCancelled] - Return true to abort between batches
|
|
448
|
+
* @yields {{type: string, [key: string]: any}}
|
|
449
|
+
*/
|
|
450
|
+
export async function* streamGrantPlanSubscribers(planId, opts = {}) {
|
|
451
|
+
const {
|
|
452
|
+
granterAddress,
|
|
453
|
+
lcdUrl,
|
|
454
|
+
preferRpc = true,
|
|
455
|
+
rpcClient,
|
|
456
|
+
broadcast,
|
|
457
|
+
grantOpts = {},
|
|
458
|
+
batchSize = 5,
|
|
459
|
+
isCancelled = () => false,
|
|
460
|
+
} = opts;
|
|
461
|
+
|
|
462
|
+
if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
|
|
463
|
+
if (typeof broadcast !== 'function') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'broadcast function is required');
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
yield { type: 'status', msg: 'Fetching plan subscribers...' };
|
|
467
|
+
const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
|
|
468
|
+
|
|
469
|
+
const now = new Date();
|
|
470
|
+
const activeSubs = subscribers.filter(s => {
|
|
471
|
+
if (s.status && s.status !== 'active') return false;
|
|
472
|
+
if (s.inactive_at && new Date(s.inactive_at) <= now) return false;
|
|
473
|
+
return true;
|
|
474
|
+
});
|
|
475
|
+
const uniqueAddrs = [...new Set(activeSubs.map(s => s.acc_address || s.address))]
|
|
476
|
+
.filter(a => a && a !== granterAddress && !isSameKey(a, granterAddress));
|
|
477
|
+
|
|
478
|
+
yield { type: 'status', msg: `Found ${activeSubs.length} active subscribers (${uniqueAddrs.length} unique, excl. self)` };
|
|
479
|
+
|
|
480
|
+
if (uniqueAddrs.length === 0) {
|
|
481
|
+
yield { type: 'done', granted: 0, skipped: 0, total: 0, msg: 'No active subscribers (excluding self)' };
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
yield { type: 'status', msg: 'Checking existing grants...' };
|
|
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);
|
|
490
|
+
const existingGrantees = new Set(existing.map(g => g.grantee));
|
|
491
|
+
const needGrant = uniqueAddrs.filter(a => !existingGrantees.has(a));
|
|
492
|
+
const skipped = uniqueAddrs.length - needGrant.length;
|
|
493
|
+
|
|
494
|
+
yield { type: 'status', msg: `${existingGrantees.size} existing grants found. ${needGrant.length} need granting, ${skipped} already covered.` };
|
|
495
|
+
|
|
496
|
+
if (needGrant.length === 0) {
|
|
497
|
+
yield { type: 'done', granted: 0, skipped, total: 0, msg: 'All subscribers already have grants' };
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const totalBatches = Math.ceil(needGrant.length / batchSize);
|
|
502
|
+
let granted = 0;
|
|
503
|
+
const errors = [];
|
|
504
|
+
|
|
505
|
+
for (let i = 0; i < needGrant.length; i += batchSize) {
|
|
506
|
+
if (isCancelled()) break;
|
|
507
|
+
|
|
508
|
+
const batchNum = Math.floor(i / batchSize) + 1;
|
|
509
|
+
const batch = needGrant.slice(i, i + batchSize);
|
|
510
|
+
const shortAddrs = batch.map(a => a.slice(0, 12) + '...' + a.slice(-6)).join(', ');
|
|
511
|
+
|
|
512
|
+
yield { type: 'batch_start', batch: batchNum, total: totalBatches, count: batch.length, addresses: shortAddrs };
|
|
513
|
+
|
|
514
|
+
const msgs = batch.map(grantee => buildFeeGrantMsg(granterAddress, grantee, grantOpts));
|
|
515
|
+
|
|
516
|
+
const t0 = Date.now();
|
|
517
|
+
try {
|
|
518
|
+
const result = await broadcast(msgs, `Fee grant batch ${batchNum}/${totalBatches}`);
|
|
519
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
520
|
+
|
|
521
|
+
if (result.code !== 0) {
|
|
522
|
+
const errMsg = result.rawLog || `TX failed code=${result.code}`;
|
|
523
|
+
yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: errMsg, elapsed };
|
|
524
|
+
errors.push(`Batch ${batchNum}: ${errMsg}`);
|
|
525
|
+
} else {
|
|
526
|
+
granted += batch.length;
|
|
527
|
+
yield {
|
|
528
|
+
type: 'batch_ok',
|
|
529
|
+
batch: batchNum,
|
|
530
|
+
total: totalBatches,
|
|
531
|
+
granted: batch.length,
|
|
532
|
+
totalGranted: granted,
|
|
533
|
+
txHash: result.transactionHash,
|
|
534
|
+
elapsed,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
} catch (e) {
|
|
538
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
539
|
+
yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: e.message, elapsed };
|
|
540
|
+
errors.push(`Batch ${batchNum}: ${e.message}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
yield {
|
|
545
|
+
type: 'done',
|
|
546
|
+
granted,
|
|
547
|
+
skipped,
|
|
548
|
+
total: needGrant.length,
|
|
549
|
+
errors: errors.length ? errors : undefined,
|
|
550
|
+
};
|
|
551
|
+
} catch (e) {
|
|
552
|
+
yield { type: 'error', msg: e.message };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ─── Gas Cost Analytics ──────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Compute how many udvpn the granter has spent on fee-granted transactions
|
|
560
|
+
* for a plan's subscribers. Iterates each subscriber, pulls their outgoing
|
|
561
|
+
* TXs via LCD, and sums fees where `fee.granter === granterAddress`.
|
|
562
|
+
*
|
|
563
|
+
* @param {number|string} planId
|
|
564
|
+
* @param {object} opts
|
|
565
|
+
* @param {string} opts.granterAddress - Address that paid the fees (plan owner)
|
|
566
|
+
* @param {string} opts.lcdUrl - LCD endpoint
|
|
567
|
+
* @param {number} [opts.txLimit=100] - Max TXs to inspect per subscriber
|
|
568
|
+
* @param {(info: {processed:number, total:number, address:string}) => void} [opts.onProgress]
|
|
569
|
+
* @returns {Promise<{ totalUdvpn: number, txCount: number, byAddress: Record<string, {udvpn:number, txCount:number}>, subscriberCount: number }>}
|
|
570
|
+
*/
|
|
571
|
+
export async function computeFeeGrantGasCosts(planId, opts = {}) {
|
|
572
|
+
const { granterAddress, lcdUrl, txLimit = 100, onProgress } = opts;
|
|
573
|
+
if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
|
|
574
|
+
|
|
575
|
+
const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
|
|
576
|
+
const subscriberAddrs = [...new Set(subscribers.map(s => s.acc_address || s.address))]
|
|
577
|
+
.filter(a => a && a !== granterAddress);
|
|
578
|
+
|
|
579
|
+
if (subscriberAddrs.length === 0) {
|
|
580
|
+
return { totalUdvpn: 0, txCount: 0, byAddress: {}, subscriberCount: 0 };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let totalUdvpn = 0;
|
|
584
|
+
let txCount = 0;
|
|
585
|
+
const byAddress = {};
|
|
586
|
+
|
|
587
|
+
const base = lcdUrl || LCD_ENDPOINTS[0].url;
|
|
588
|
+
for (let idx = 0; idx < subscriberAddrs.length; idx++) {
|
|
589
|
+
const addr = subscriberAddrs[idx];
|
|
590
|
+
try {
|
|
591
|
+
const path =
|
|
592
|
+
`/cosmos/tx/v1beta1/txs?events=${encodeURIComponent("message.sender='" + addr + "'")}` +
|
|
593
|
+
`&pagination.limit=${txLimit}&order_by=2`;
|
|
594
|
+
const txData = await lcdQuery(path, { lcdUrl: base });
|
|
595
|
+
const rawTxs = txData.txs || [];
|
|
596
|
+
|
|
597
|
+
let addrGas = 0;
|
|
598
|
+
let addrTxCount = 0;
|
|
599
|
+
|
|
600
|
+
for (const tx of rawTxs) {
|
|
601
|
+
const fee = tx?.auth_info?.fee;
|
|
602
|
+
if (fee?.granter === granterAddress) {
|
|
603
|
+
const udvpnFee = (fee.amount || []).find(f => f.denom === 'udvpn');
|
|
604
|
+
if (udvpnFee) {
|
|
605
|
+
addrGas += parseInt(udvpnFee.amount, 10);
|
|
606
|
+
addrTxCount++;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (addrTxCount > 0) {
|
|
612
|
+
byAddress[addr] = { udvpn: addrGas, txCount: addrTxCount };
|
|
613
|
+
totalUdvpn += addrGas;
|
|
614
|
+
txCount += addrTxCount;
|
|
615
|
+
}
|
|
616
|
+
} catch { /* skip this subscriber on LCD failure */ }
|
|
617
|
+
|
|
618
|
+
if (onProgress) onProgress({ processed: idx + 1, total: subscriberAddrs.length, address: addr });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return { totalUdvpn, txCount, byAddress, subscriberCount: subscriberAddrs.length };
|
|
622
|
+
}
|
package/chain/index.js
CHANGED
|
@@ -359,9 +359,15 @@ export function txResponse(result) {
|
|
|
359
359
|
* Find an existing active session for a wallet+node pair.
|
|
360
360
|
* Returns session ID (BigInt) or null. Use this to avoid double-paying.
|
|
361
361
|
* Delegates to chain/queries.js RPC-first implementation.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} lcdUrl - LCD endpoint URL
|
|
364
|
+
* @param {string} walletAddr - sent1... wallet address
|
|
365
|
+
* @param {string} nodeAddr - sentnode1... node address
|
|
366
|
+
* @param {object} [opts] - Optional. Pass `{ onStaleDuplicate: (BigInt) => void }` to
|
|
367
|
+
* receive fire-and-forget cancellation callbacks for stale duplicate sessions.
|
|
362
368
|
*/
|
|
363
|
-
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
364
|
-
return _rpcFindExistingSession(lcdUrl, walletAddr, nodeAddr);
|
|
369
|
+
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts) {
|
|
370
|
+
return _rpcFindExistingSession(lcdUrl, walletAddr, nodeAddr, opts);
|
|
365
371
|
}
|
|
366
372
|
|
|
367
373
|
/**
|
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
|
|
@@ -81,8 +82,22 @@ export async function getBalance(client, address) {
|
|
|
81
82
|
* Find an existing active session for a wallet+node pair.
|
|
82
83
|
* Returns session ID (BigInt) or null. Use this to avoid double-paying.
|
|
83
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>}
|
|
84
99
|
*/
|
|
85
|
-
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
100
|
+
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr, opts = {}) {
|
|
86
101
|
let sessions;
|
|
87
102
|
|
|
88
103
|
// RPC-first: returns decoded, flat session objects
|
|
@@ -102,6 +117,8 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
|
102
117
|
});
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
// Collect all non-exhausted active sessions for this node
|
|
121
|
+
const matching = [];
|
|
105
122
|
for (const s of sessions) {
|
|
106
123
|
if ((s.node_address || s.node) !== nodeAddr) continue;
|
|
107
124
|
// RPC returns status as number (1=active), LCD as string
|
|
@@ -111,9 +128,22 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
|
111
128
|
if (acct && acct !== walletAddr) continue;
|
|
112
129
|
const maxBytes = parseInt(s.max_bytes || '0');
|
|
113
130
|
const used = parseInt(s.download_bytes || '0') + parseInt(s.upload_bytes || '0');
|
|
114
|
-
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
|
+
}
|
|
115
144
|
}
|
|
116
|
-
|
|
145
|
+
|
|
146
|
+
return matching[0];
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
/**
|
|
@@ -588,8 +618,80 @@ export async function querySubscriptionAllocations(subscriptionId, lcdUrl) {
|
|
|
588
618
|
} catch { return []; }
|
|
589
619
|
}
|
|
590
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
|
+
|
|
591
636
|
// ─── Plan Subscriber Helpers (v25b) ──────────────────────────────────────────
|
|
592
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
|
+
|
|
593
695
|
/**
|
|
594
696
|
* Query all subscriptions for a plan. Supports owner filtering.
|
|
595
697
|
* RPC-first with LCD fallback.
|
|
@@ -841,3 +943,75 @@ export function saveVpnSettings(settings) {
|
|
|
841
943
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
842
944
|
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
843
945
|
}
|
|
946
|
+
|
|
947
|
+
// ─── TX Hash Lookup (RPC-first, LCD fallback) ───────────────────────────────
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Fetch a transaction by hash. RPC is tried first; if it fails or the TX is
|
|
951
|
+
* not found, falls back to the LCD REST endpoint.
|
|
952
|
+
*
|
|
953
|
+
* Accepts bare hex or 0x-prefixed hex for the hash.
|
|
954
|
+
* Returns the same normalised shape regardless of source:
|
|
955
|
+
* { hash, height, code, rawLog, events, gasUsed, gasWanted }
|
|
956
|
+
*
|
|
957
|
+
* Use this to re-fetch TX events after a crash/restart or from a different
|
|
958
|
+
* process (CosmJS only returns DeliverTxResponse inline from signAndBroadcast).
|
|
959
|
+
*
|
|
960
|
+
* @param {string} txHash - Transaction hash (bare hex or 0x-prefixed)
|
|
961
|
+
* @param {object} [opts]
|
|
962
|
+
* @param {string} [opts.rpcUrl] - RPC endpoint (uses cached client if omitted)
|
|
963
|
+
* @param {string} [opts.lcdUrl] - LCD endpoint for fallback
|
|
964
|
+
* @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>}
|
|
965
|
+
*/
|
|
966
|
+
export async function getTxByHash(txHash, opts = {}) {
|
|
967
|
+
const hex = txHash.replace(/^0x/i, '').toUpperCase();
|
|
968
|
+
|
|
969
|
+
// ── RPC-first ──────────────────────────────────────────────────────────────
|
|
970
|
+
try {
|
|
971
|
+
let rpc;
|
|
972
|
+
if (opts.rpcUrl) {
|
|
973
|
+
const { createRpcQueryClient } = await import('./rpc.js');
|
|
974
|
+
rpc = await createRpcQueryClient(opts.rpcUrl);
|
|
975
|
+
} else {
|
|
976
|
+
rpc = await getRpcClient();
|
|
977
|
+
}
|
|
978
|
+
if (rpc?.tmClient) {
|
|
979
|
+
const result = await rpcGetTxByHash(rpc.tmClient, hex);
|
|
980
|
+
return result;
|
|
981
|
+
}
|
|
982
|
+
} catch (rpcErr) {
|
|
983
|
+
// "tx not found" from RPC → fall through to LCD
|
|
984
|
+
const msg = rpcErr?.message || '';
|
|
985
|
+
if (!msg.toLowerCase().includes('not found') && !msg.toLowerCase().includes('404')) {
|
|
986
|
+
// Real connectivity error — still fall through, LCD may succeed
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ── LCD fallback ───────────────────────────────────────────────────────────
|
|
991
|
+
try {
|
|
992
|
+
const doLcd = async (baseUrl) => {
|
|
993
|
+
const data = await lcdQuery(`/cosmos/tx/v1beta1/txs/${hex}`, { lcdUrl: baseUrl });
|
|
994
|
+
const txResp = data?.tx_response;
|
|
995
|
+
if (!txResp) return null;
|
|
996
|
+
const events = (txResp.events || []).map(ev => ({
|
|
997
|
+
type: ev.type,
|
|
998
|
+
attributes: (ev.attributes || []).map(attr => ({
|
|
999
|
+
key: attr.key,
|
|
1000
|
+
value: attr.value,
|
|
1001
|
+
})),
|
|
1002
|
+
}));
|
|
1003
|
+
return {
|
|
1004
|
+
hash: (txResp.txhash || hex).toUpperCase(),
|
|
1005
|
+
height: parseInt(txResp.height || '0', 10),
|
|
1006
|
+
code: txResp.code || 0,
|
|
1007
|
+
rawLog: txResp.raw_log || '',
|
|
1008
|
+
events,
|
|
1009
|
+
gasUsed: String(txResp.gas_used || '0'),
|
|
1010
|
+
gasWanted: String(txResp.gas_wanted || '0'),
|
|
1011
|
+
};
|
|
1012
|
+
};
|
|
1013
|
+
if (opts.lcdUrl) return await doLcd(opts.lcdUrl);
|
|
1014
|
+
const { result } = await tryWithFallback(LCD_ENDPOINTS, doLcd, `getTxByHash ${hex}`);
|
|
1015
|
+
return result;
|
|
1016
|
+
} catch { return null; }
|
|
1017
|
+
}
|