blue-js-sdk 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/chain/fee-grants.js +196 -0
- package/chain/index.js +8 -2
- package/chain/queries.js +105 -3
- package/chain/rpc.js +58 -2
- package/client.js +17 -1
- package/connection/connect.js +17 -5
- package/connection/disconnect.js +86 -10
- package/connection/index.js +2 -0
- package/cosmjs-setup.js +26 -2
- package/index.js +5 -1
- package/node-connect.js +101 -13
- package/operator.js +2 -0
- package/package.json +2 -5
- 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/chain/fee-grants.js
CHANGED
|
@@ -354,3 +354,199 @@ export function monitorFeeGrants(opts = {}) {
|
|
|
354
354
|
|
|
355
355
|
return emitter;
|
|
356
356
|
}
|
|
357
|
+
|
|
358
|
+
// ─── Streaming Batch Grant (for SSE / progress UIs) ──────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Stream progress as we grant fee allowances to all plan subscribers in batches.
|
|
362
|
+
*
|
|
363
|
+
* Async generator. Yields events with `{ type, ...payload }`:
|
|
364
|
+
* - status { msg } — human-readable status line
|
|
365
|
+
* - batch_start { batch, total, count, addresses } — about to broadcast a batch
|
|
366
|
+
* - batch_ok { batch, total, granted, totalGranted, txHash, elapsed }
|
|
367
|
+
* - batch_error { batch, total, error, elapsed }
|
|
368
|
+
* - done { granted, skipped, total, errors? }
|
|
369
|
+
* - error { msg }
|
|
370
|
+
*
|
|
371
|
+
* The caller passes a `broadcast(msgs, memo)` function — any safe-broadcaster
|
|
372
|
+
* with the Plan Manager's mutex + sequence-retry semantics works. Consumer
|
|
373
|
+
* routes layer SSE (`res.write('data: ...\n\n')`) on top of these events.
|
|
374
|
+
*
|
|
375
|
+
* @param {number|string} planId
|
|
376
|
+
* @param {object} opts
|
|
377
|
+
* @param {string} opts.granterAddress - Plan owner paying fees
|
|
378
|
+
* @param {string} opts.lcdUrl - LCD endpoint
|
|
379
|
+
* @param {(msgs: Array, memo: string) => Promise<{code:number, rawLog?:string, transactionHash?:string}>} opts.broadcast
|
|
380
|
+
* @param {object} [opts.grantOpts] - { spendLimit, expiration } for BasicAllowance
|
|
381
|
+
* @param {number} [opts.batchSize=5] - Msgs per TX
|
|
382
|
+
* @param {() => boolean} [opts.isCancelled] - Return true to abort between batches
|
|
383
|
+
* @yields {{type: string, [key: string]: any}}
|
|
384
|
+
*/
|
|
385
|
+
export async function* streamGrantPlanSubscribers(planId, opts = {}) {
|
|
386
|
+
const {
|
|
387
|
+
granterAddress,
|
|
388
|
+
lcdUrl,
|
|
389
|
+
broadcast,
|
|
390
|
+
grantOpts = {},
|
|
391
|
+
batchSize = 5,
|
|
392
|
+
isCancelled = () => false,
|
|
393
|
+
} = opts;
|
|
394
|
+
|
|
395
|
+
if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
|
|
396
|
+
if (typeof broadcast !== 'function') throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'broadcast function is required');
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
yield { type: 'status', msg: 'Fetching plan subscribers...' };
|
|
400
|
+
const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
|
|
401
|
+
|
|
402
|
+
const now = new Date();
|
|
403
|
+
const activeSubs = subscribers.filter(s => {
|
|
404
|
+
if (s.status && s.status !== 'active') return false;
|
|
405
|
+
if (s.inactive_at && new Date(s.inactive_at) <= now) return false;
|
|
406
|
+
return true;
|
|
407
|
+
});
|
|
408
|
+
const uniqueAddrs = [...new Set(activeSubs.map(s => s.acc_address || s.address))]
|
|
409
|
+
.filter(a => a && a !== granterAddress && !isSameKey(a, granterAddress));
|
|
410
|
+
|
|
411
|
+
yield { type: 'status', msg: `Found ${activeSubs.length} active subscribers (${uniqueAddrs.length} unique, excl. self)` };
|
|
412
|
+
|
|
413
|
+
if (uniqueAddrs.length === 0) {
|
|
414
|
+
yield { type: 'done', granted: 0, skipped: 0, total: 0, msg: 'No active subscribers (excluding self)' };
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
yield { type: 'status', msg: 'Checking existing grants...' };
|
|
419
|
+
const existing = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
|
|
420
|
+
const existingGrantees = new Set(existing.map(g => g.grantee));
|
|
421
|
+
const needGrant = uniqueAddrs.filter(a => !existingGrantees.has(a));
|
|
422
|
+
const skipped = uniqueAddrs.length - needGrant.length;
|
|
423
|
+
|
|
424
|
+
yield { type: 'status', msg: `${existingGrantees.size} existing grants found. ${needGrant.length} need granting, ${skipped} already covered.` };
|
|
425
|
+
|
|
426
|
+
if (needGrant.length === 0) {
|
|
427
|
+
yield { type: 'done', granted: 0, skipped, total: 0, msg: 'All subscribers already have grants' };
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const totalBatches = Math.ceil(needGrant.length / batchSize);
|
|
432
|
+
let granted = 0;
|
|
433
|
+
const errors = [];
|
|
434
|
+
|
|
435
|
+
for (let i = 0; i < needGrant.length; i += batchSize) {
|
|
436
|
+
if (isCancelled()) break;
|
|
437
|
+
|
|
438
|
+
const batchNum = Math.floor(i / batchSize) + 1;
|
|
439
|
+
const batch = needGrant.slice(i, i + batchSize);
|
|
440
|
+
const shortAddrs = batch.map(a => a.slice(0, 12) + '...' + a.slice(-6)).join(', ');
|
|
441
|
+
|
|
442
|
+
yield { type: 'batch_start', batch: batchNum, total: totalBatches, count: batch.length, addresses: shortAddrs };
|
|
443
|
+
|
|
444
|
+
const msgs = batch.map(grantee => buildFeeGrantMsg(granterAddress, grantee, grantOpts));
|
|
445
|
+
|
|
446
|
+
const t0 = Date.now();
|
|
447
|
+
try {
|
|
448
|
+
const result = await broadcast(msgs, `Fee grant batch ${batchNum}/${totalBatches}`);
|
|
449
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
450
|
+
|
|
451
|
+
if (result.code !== 0) {
|
|
452
|
+
const errMsg = result.rawLog || `TX failed code=${result.code}`;
|
|
453
|
+
yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: errMsg, elapsed };
|
|
454
|
+
errors.push(`Batch ${batchNum}: ${errMsg}`);
|
|
455
|
+
} else {
|
|
456
|
+
granted += batch.length;
|
|
457
|
+
yield {
|
|
458
|
+
type: 'batch_ok',
|
|
459
|
+
batch: batchNum,
|
|
460
|
+
total: totalBatches,
|
|
461
|
+
granted: batch.length,
|
|
462
|
+
totalGranted: granted,
|
|
463
|
+
txHash: result.transactionHash,
|
|
464
|
+
elapsed,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
} catch (e) {
|
|
468
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
469
|
+
yield { type: 'batch_error', batch: batchNum, total: totalBatches, error: e.message, elapsed };
|
|
470
|
+
errors.push(`Batch ${batchNum}: ${e.message}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
yield {
|
|
475
|
+
type: 'done',
|
|
476
|
+
granted,
|
|
477
|
+
skipped,
|
|
478
|
+
total: needGrant.length,
|
|
479
|
+
errors: errors.length ? errors : undefined,
|
|
480
|
+
};
|
|
481
|
+
} catch (e) {
|
|
482
|
+
yield { type: 'error', msg: e.message };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Gas Cost Analytics ──────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Compute how many udvpn the granter has spent on fee-granted transactions
|
|
490
|
+
* for a plan's subscribers. Iterates each subscriber, pulls their outgoing
|
|
491
|
+
* TXs via LCD, and sums fees where `fee.granter === granterAddress`.
|
|
492
|
+
*
|
|
493
|
+
* @param {number|string} planId
|
|
494
|
+
* @param {object} opts
|
|
495
|
+
* @param {string} opts.granterAddress - Address that paid the fees (plan owner)
|
|
496
|
+
* @param {string} opts.lcdUrl - LCD endpoint
|
|
497
|
+
* @param {number} [opts.txLimit=100] - Max TXs to inspect per subscriber
|
|
498
|
+
* @param {(info: {processed:number, total:number, address:string}) => void} [opts.onProgress]
|
|
499
|
+
* @returns {Promise<{ totalUdvpn: number, txCount: number, byAddress: Record<string, {udvpn:number, txCount:number}>, subscriberCount: number }>}
|
|
500
|
+
*/
|
|
501
|
+
export async function computeFeeGrantGasCosts(planId, opts = {}) {
|
|
502
|
+
const { granterAddress, lcdUrl, txLimit = 100, onProgress } = opts;
|
|
503
|
+
if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
|
|
504
|
+
|
|
505
|
+
const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
|
|
506
|
+
const subscriberAddrs = [...new Set(subscribers.map(s => s.acc_address || s.address))]
|
|
507
|
+
.filter(a => a && a !== granterAddress);
|
|
508
|
+
|
|
509
|
+
if (subscriberAddrs.length === 0) {
|
|
510
|
+
return { totalUdvpn: 0, txCount: 0, byAddress: {}, subscriberCount: 0 };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let totalUdvpn = 0;
|
|
514
|
+
let txCount = 0;
|
|
515
|
+
const byAddress = {};
|
|
516
|
+
|
|
517
|
+
const base = lcdUrl || LCD_ENDPOINTS[0].url;
|
|
518
|
+
for (let idx = 0; idx < subscriberAddrs.length; idx++) {
|
|
519
|
+
const addr = subscriberAddrs[idx];
|
|
520
|
+
try {
|
|
521
|
+
const path =
|
|
522
|
+
`/cosmos/tx/v1beta1/txs?events=${encodeURIComponent("message.sender='" + addr + "'")}` +
|
|
523
|
+
`&pagination.limit=${txLimit}&order_by=2`;
|
|
524
|
+
const txData = await lcdQuery(path, { lcdUrl: base });
|
|
525
|
+
const rawTxs = txData.txs || [];
|
|
526
|
+
|
|
527
|
+
let addrGas = 0;
|
|
528
|
+
let addrTxCount = 0;
|
|
529
|
+
|
|
530
|
+
for (const tx of rawTxs) {
|
|
531
|
+
const fee = tx?.auth_info?.fee;
|
|
532
|
+
if (fee?.granter === granterAddress) {
|
|
533
|
+
const udvpnFee = (fee.amount || []).find(f => f.denom === 'udvpn');
|
|
534
|
+
if (udvpnFee) {
|
|
535
|
+
addrGas += parseInt(udvpnFee.amount, 10);
|
|
536
|
+
addrTxCount++;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (addrTxCount > 0) {
|
|
542
|
+
byAddress[addr] = { udvpn: addrGas, txCount: addrTxCount };
|
|
543
|
+
totalUdvpn += addrGas;
|
|
544
|
+
txCount += addrTxCount;
|
|
545
|
+
}
|
|
546
|
+
} catch { /* skip this subscriber on LCD failure */ }
|
|
547
|
+
|
|
548
|
+
if (onProgress) onProgress({ processed: idx + 1, total: subscriberAddrs.length, address: addr });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { totalUdvpn, txCount, byAddress, subscriberCount: subscriberAddrs.length };
|
|
552
|
+
}
|
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));
|
|
115
132
|
}
|
|
116
|
-
|
|
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
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return matching[0];
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
/**
|
|
@@ -841,3 +871,75 @@ export function saveVpnSettings(settings) {
|
|
|
841
871
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
842
872
|
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
843
873
|
}
|
|
874
|
+
|
|
875
|
+
// ─── TX Hash Lookup (RPC-first, LCD fallback) ───────────────────────────────
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Fetch a transaction by hash. RPC is tried first; if it fails or the TX is
|
|
879
|
+
* not found, falls back to the LCD REST endpoint.
|
|
880
|
+
*
|
|
881
|
+
* Accepts bare hex or 0x-prefixed hex for the hash.
|
|
882
|
+
* Returns the same normalised shape regardless of source:
|
|
883
|
+
* { hash, height, code, rawLog, events, gasUsed, gasWanted }
|
|
884
|
+
*
|
|
885
|
+
* Use this to re-fetch TX events after a crash/restart or from a different
|
|
886
|
+
* process (CosmJS only returns DeliverTxResponse inline from signAndBroadcast).
|
|
887
|
+
*
|
|
888
|
+
* @param {string} txHash - Transaction hash (bare hex or 0x-prefixed)
|
|
889
|
+
* @param {object} [opts]
|
|
890
|
+
* @param {string} [opts.rpcUrl] - RPC endpoint (uses cached client if omitted)
|
|
891
|
+
* @param {string} [opts.lcdUrl] - LCD endpoint for fallback
|
|
892
|
+
* @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string } | null>}
|
|
893
|
+
*/
|
|
894
|
+
export async function getTxByHash(txHash, opts = {}) {
|
|
895
|
+
const hex = txHash.replace(/^0x/i, '').toUpperCase();
|
|
896
|
+
|
|
897
|
+
// ── RPC-first ──────────────────────────────────────────────────────────────
|
|
898
|
+
try {
|
|
899
|
+
let rpc;
|
|
900
|
+
if (opts.rpcUrl) {
|
|
901
|
+
const { createRpcQueryClient } = await import('./rpc.js');
|
|
902
|
+
rpc = await createRpcQueryClient(opts.rpcUrl);
|
|
903
|
+
} else {
|
|
904
|
+
rpc = await getRpcClient();
|
|
905
|
+
}
|
|
906
|
+
if (rpc?.tmClient) {
|
|
907
|
+
const result = await rpcGetTxByHash(rpc.tmClient, hex);
|
|
908
|
+
return result;
|
|
909
|
+
}
|
|
910
|
+
} catch (rpcErr) {
|
|
911
|
+
// "tx not found" from RPC → fall through to LCD
|
|
912
|
+
const msg = rpcErr?.message || '';
|
|
913
|
+
if (!msg.toLowerCase().includes('not found') && !msg.toLowerCase().includes('404')) {
|
|
914
|
+
// Real connectivity error — still fall through, LCD may succeed
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── LCD fallback ───────────────────────────────────────────────────────────
|
|
919
|
+
try {
|
|
920
|
+
const doLcd = async (baseUrl) => {
|
|
921
|
+
const data = await lcdQuery(`/cosmos/tx/v1beta1/txs/${hex}`, { lcdUrl: baseUrl });
|
|
922
|
+
const txResp = data?.tx_response;
|
|
923
|
+
if (!txResp) return null;
|
|
924
|
+
const events = (txResp.events || []).map(ev => ({
|
|
925
|
+
type: ev.type,
|
|
926
|
+
attributes: (ev.attributes || []).map(attr => ({
|
|
927
|
+
key: attr.key,
|
|
928
|
+
value: attr.value,
|
|
929
|
+
})),
|
|
930
|
+
}));
|
|
931
|
+
return {
|
|
932
|
+
hash: (txResp.txhash || hex).toUpperCase(),
|
|
933
|
+
height: parseInt(txResp.height || '0', 10),
|
|
934
|
+
code: txResp.code || 0,
|
|
935
|
+
rawLog: txResp.raw_log || '',
|
|
936
|
+
events,
|
|
937
|
+
gasUsed: String(txResp.gas_used || '0'),
|
|
938
|
+
gasWanted: String(txResp.gas_wanted || '0'),
|
|
939
|
+
};
|
|
940
|
+
};
|
|
941
|
+
if (opts.lcdUrl) return await doLcd(opts.lcdUrl);
|
|
942
|
+
const { result } = await tryWithFallback(LCD_ENDPOINTS, doLcd, `getTxByHash ${hex}`);
|
|
943
|
+
return result;
|
|
944
|
+
} catch { return null; }
|
|
945
|
+
}
|
package/chain/rpc.js
CHANGED
|
@@ -402,11 +402,17 @@ function decodeAllocation(fields) {
|
|
|
402
402
|
/**
|
|
403
403
|
* Query active nodes via RPC.
|
|
404
404
|
*
|
|
405
|
+
* NOTE ON PAGINATION: Sentinel v3's `QueryNodes` truncates at the requested
|
|
406
|
+
* `limit` and does NOT emit `pagination.next_key`. A standard Cosmos
|
|
407
|
+
* "loop while next_key is non-empty" pattern terminates on the first call
|
|
408
|
+
* and silently loses data. Request above the chain's current hard ceiling
|
|
409
|
+
* (~1048 active nodes as of 2026-04). Default raised to 10000.
|
|
410
|
+
*
|
|
405
411
|
* @param {{ queryClient: QueryClient }} client - From createRpcQueryClient()
|
|
406
412
|
* @param {{ status?: number, limit?: number }} [opts]
|
|
407
413
|
* @returns {Promise<Array<{ address: string, gigabyte_prices: Array, hourly_prices: Array, remote_addrs: string[], status: number }>>}
|
|
408
414
|
*/
|
|
409
|
-
export async function rpcQueryNodes(client, { status = 1, limit =
|
|
415
|
+
export async function rpcQueryNodes(client, { status = 1, limit = 10000 } = {}) {
|
|
410
416
|
const path = '/sentinel.node.v3.QueryService/QueryNodes';
|
|
411
417
|
const request = concat([
|
|
412
418
|
encodeEnum(1, status), // status field
|
|
@@ -446,12 +452,18 @@ export async function rpcQueryNode(client, address) {
|
|
|
446
452
|
/**
|
|
447
453
|
* Query nodes linked to a plan via RPC.
|
|
448
454
|
*
|
|
455
|
+
* NOTE ON PAGINATION: `QueryNodesForPlan` silently truncates at the requested
|
|
456
|
+
* `limit` with no `next_key`. Observed 2026-04: plan 36 has 803 active nodes
|
|
457
|
+
* but `limit=500` returns exactly 500 with no indication more exist. Default
|
|
458
|
+
* raised to 10000. If a plan grows beyond that, raise further — the chain's
|
|
459
|
+
* own ceiling is the effective limit.
|
|
460
|
+
*
|
|
449
461
|
* @param {{ queryClient: QueryClient }} client
|
|
450
462
|
* @param {number|bigint} planId
|
|
451
463
|
* @param {{ status?: number, limit?: number }} [opts]
|
|
452
464
|
* @returns {Promise<Array>}
|
|
453
465
|
*/
|
|
454
|
-
export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit =
|
|
466
|
+
export async function rpcQueryNodesForPlan(client, planId, { status = 1, limit = 10000 } = {}) {
|
|
455
467
|
const path = '/sentinel.node.v3.QueryService/QueryNodesForPlan';
|
|
456
468
|
const request = concat([
|
|
457
469
|
encodeUint64(1, planId), // id
|
|
@@ -891,6 +903,50 @@ export async function rpcQueryProvider(client, provAddress) {
|
|
|
891
903
|
}
|
|
892
904
|
}
|
|
893
905
|
|
|
906
|
+
// ─── TX Hash Lookup ─────────────────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Fetch a transaction by hash via Tendermint RPC.
|
|
910
|
+
* Accepts bare hex (64 chars) or 0x-prefixed hex. Returns a normalized shape
|
|
911
|
+
* matching the LCD cosmos/tx/v1beta1/txs/{hash} response so callers don't
|
|
912
|
+
* need to handle both formats.
|
|
913
|
+
*
|
|
914
|
+
* @param {import('@cosmjs/tendermint-rpc').Tendermint37Client} tmClient - From createRpcQueryClient().tmClient
|
|
915
|
+
* @param {string} txHash - TX hash as hex string (bare or 0x-prefixed)
|
|
916
|
+
* @returns {Promise<{ hash: string, height: number, code: number, rawLog: string, events: Array<{ type: string, attributes: Array<{ key: string, value: string }> }>, gasUsed: string, gasWanted: string }>}
|
|
917
|
+
* @throws {Error} If the transaction is not found or RPC call fails
|
|
918
|
+
*/
|
|
919
|
+
export async function rpcGetTxByHash(tmClient, txHash) {
|
|
920
|
+
// Strip optional 0x prefix and normalise to upper-case for consistency
|
|
921
|
+
const hex = txHash.replace(/^0x/i, '').toUpperCase();
|
|
922
|
+
// Decode hex string → Uint8Array
|
|
923
|
+
const hashBytes = new Uint8Array(hex.length / 2);
|
|
924
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
925
|
+
hashBytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const response = await tmClient.tx({ hash: hashBytes });
|
|
929
|
+
|
|
930
|
+
// Normalise events: TxData.events is already decoded by CosmJS
|
|
931
|
+
const events = (response.result.events || []).map(ev => ({
|
|
932
|
+
type: ev.type,
|
|
933
|
+
attributes: (ev.attributes || []).map(attr => ({
|
|
934
|
+
key: attr.key,
|
|
935
|
+
value: attr.value,
|
|
936
|
+
})),
|
|
937
|
+
}));
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
hash: hex,
|
|
941
|
+
height: response.height,
|
|
942
|
+
code: response.result.code,
|
|
943
|
+
rawLog: response.result.log || '',
|
|
944
|
+
events,
|
|
945
|
+
gasUsed: String(response.result.gasUsed),
|
|
946
|
+
gasWanted: String(response.result.gasWanted),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
894
950
|
export async function rpcQueryBalance(client, address, denom = 'udvpn') {
|
|
895
951
|
const path = '/cosmos.bank.v1beta1.Query/Balance';
|
|
896
952
|
const request = concat([
|
package/client.js
CHANGED
|
@@ -28,6 +28,7 @@ import { EventEmitter } from 'events';
|
|
|
28
28
|
import {
|
|
29
29
|
connectDirect, connectViaPlan, connectAuto, queryOnlineNodes,
|
|
30
30
|
disconnect as sdkDisconnect, disconnectState,
|
|
31
|
+
disconnectAndEndSession as sdkDisconnectAndEndSession, disconnectStateAndEndSession,
|
|
31
32
|
isConnected as sdkIsConnected, getStatus as sdkGetStatus,
|
|
32
33
|
registerCleanupHandlers, setSystemProxy, clearSystemProxy,
|
|
33
34
|
events as sdkEvents, ConnectionState,
|
|
@@ -125,13 +126,28 @@ export class SentinelClient extends EventEmitter {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
|
-
*
|
|
129
|
+
* Soft disconnect — tear down the tunnel, leave the on-chain session active.
|
|
130
|
+
*
|
|
131
|
+
* A subsequent connect() to the SAME node reuses the session (no new payment).
|
|
132
|
+
* Use for pause / temporary disconnect / network-change recovery.
|
|
133
|
+
* To settle the session and reclaim the deposit, use disconnectAndEndSession().
|
|
129
134
|
*/
|
|
130
135
|
async disconnect() {
|
|
131
136
|
await disconnectState(this._state);
|
|
132
137
|
this._connection = null;
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Hard disconnect — tear down the tunnel AND broadcast MsgCancelSession on chain.
|
|
142
|
+
*
|
|
143
|
+
* Settles the session after the ~2h window and refunds the unused deposit.
|
|
144
|
+
* Use when the user is done with this node (switching permanently or wants refund).
|
|
145
|
+
*/
|
|
146
|
+
async disconnectAndEndSession() {
|
|
147
|
+
await disconnectStateAndEndSession(this._state);
|
|
148
|
+
this._connection = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
135
151
|
/**
|
|
136
152
|
* Check if a VPN tunnel is currently active.
|
|
137
153
|
*/
|
package/connection/connect.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
events, _defaultState, progress, checkAborted,
|
|
10
10
|
warnIfNoCleanup, cachedCreateWallet, _recordMetric,
|
|
11
11
|
broadcastWithInactiveRetry, getConnectLock, setConnectLock,
|
|
12
|
-
getAbortConnect, setAbortConnect,
|
|
12
|
+
getAbortConnect, setAbortConnect, _endSessionOnChain,
|
|
13
13
|
} from './state.js';
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
import { createNodeHttpsAgent } from '../tls-trust.js';
|
|
32
32
|
import { disconnectWireGuard } from '../wireguard.js';
|
|
33
33
|
|
|
34
|
-
import { disconnectState } from './disconnect.js';
|
|
34
|
+
import { disconnectState, disconnectStateAndEndSession } from './disconnect.js';
|
|
35
35
|
import { queryOnlineNodes } from './discovery.js';
|
|
36
36
|
import {
|
|
37
37
|
recordNodeFailure, isCircuitOpen, configureCircuitBreaker,
|
|
@@ -79,8 +79,10 @@ async function connectInternal(opts, paymentStrategy, retryStrategy, state = _de
|
|
|
79
79
|
{ nodeAddress: state.connection?.nodeAddress });
|
|
80
80
|
}
|
|
81
81
|
const prev = state.connection;
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Hard disconnect: user is actively connecting to a different node,
|
|
83
|
+
// so the old session should be settled and the deposit refunded.
|
|
84
|
+
await disconnectStateAndEndSession(state);
|
|
85
|
+
if (opts.log || defaultLog) (opts.log || defaultLog)(`[connect] Ended session on ${prev?.nodeAddress || 'previous node'} — connecting to new node`);
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
const onProgress = opts.onProgress || null;
|
|
@@ -397,7 +399,17 @@ export async function connectDirect(opts) {
|
|
|
397
399
|
if (!forceNewSession) {
|
|
398
400
|
progress(onProgress, logFn, 'session', 'Checking for existing session...');
|
|
399
401
|
checkAborted(signal);
|
|
400
|
-
sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress
|
|
402
|
+
sessionId = await findExistingSession(lcd, account.address, opts.nodeAddress, {
|
|
403
|
+
// Dedup: if multiple active sessions exist for this node (stale duplicates from
|
|
404
|
+
// crashes or multi-client wallets), keep the highest-ID one. Silently cancel stale
|
|
405
|
+
// lower-ID sessions before MsgStartSession — mirrors C# SessionManager dedup pattern.
|
|
406
|
+
onStaleDuplicate: (staleId) => {
|
|
407
|
+
logFn?.(`[connect] Cancelling stale duplicate session ${staleId} on ${opts.nodeAddress}...`);
|
|
408
|
+
_endSessionOnChain(staleId, opts.mnemonic).catch(e => {
|
|
409
|
+
logFn?.(`[connect] Failed to cancel stale session ${staleId}: ${e.message}`);
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
});
|
|
401
413
|
if (sessionId && isSessionPoisoned(String(sessionId))) {
|
|
402
414
|
progress(onProgress, logFn, 'session', `Session ${sessionId} previously failed — skipping`);
|
|
403
415
|
sessionId = null;
|