blue-js-sdk 2.2.0 → 2.3.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/CHANGELOG.md +34 -0
- package/batch.js +4 -8
- package/chain/fee-grants.js +51 -0
- package/chain/queries.js +85 -8
- package/chain/rpc.js +169 -0
- package/cosmjs-setup.js +64 -370
- package/defaults.js +121 -1
- package/index.js +13 -0
- package/node-connect.js +6 -2
- package/package.json +1 -1
- package/session-manager.js +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
Every fix made during SDK creation, why it matters, and what happens if you use upstream Sentinel code directly without these fixes.
|
|
4
4
|
|
|
5
|
+
## v2.3.0 — RPC-First Migration (2026-04-14)
|
|
6
|
+
|
|
7
|
+
**100% of chain queries now use RPC-first with LCD fallback.** Protobuf/ABCI queries via Tendermint37Client are ~912x faster than LCD REST. If RPC fails, every query automatically falls back to LCD.
|
|
8
|
+
|
|
9
|
+
### JS SDK Changes
|
|
10
|
+
- **chain/rpc.js**: Added 4 new RPC functions — `rpcQueryFeeGrants`, `rpcQueryFeeGrantsIssued`, `rpcQueryAuthzGrants`, `rpcQueryProvider`
|
|
11
|
+
- **chain/queries.js**: All 22 query functions are RPC-first with LCD fallback
|
|
12
|
+
- **chain/fee-grants.js**: All 7 functions are RPC-first with LCD fallback
|
|
13
|
+
- **cosmjs-setup.js**: All 28 query bodies replaced with thin wrappers delegating to RPC-first modules
|
|
14
|
+
- **session-manager.js**: `buildSessionMap()` now uses RPC-first `querySessions()`
|
|
15
|
+
- **batch.js**: `waitForBatchSessions()` now uses RPC-first `querySessions()`
|
|
16
|
+
- **defaults.js**: Added runtime endpoint management — `addRpcEndpoint`, `removeRpcEndpoint`, `setEndpoints`, `getEndpoints`, `checkRpcEndpointHealth`, `optimizeEndpoints`
|
|
17
|
+
- **index.js**: 16 RPC query exports + 8 endpoint management exports
|
|
18
|
+
- **SDK_VERSION**: Bumped to 2.3.0
|
|
19
|
+
|
|
20
|
+
### C# SDK Changes
|
|
21
|
+
- **RpcClient.cs**: Wired into ChainClient. 17 typed query methods (sessions, subscriptions, nodes, balance, provider, fee grants, authz, allocations)
|
|
22
|
+
- **ProtobufReader.cs**: Added `DecodeSession`, `DecodeSubscription`, `DecodeProvider` decoders
|
|
23
|
+
- **ChainClient.Queries.cs**: 13 methods upgraded to RPC-first with LCD fallback
|
|
24
|
+
- **ChainClient.FeeGrants.cs**: 2 methods upgraded to RPC-first with LCD fallback
|
|
25
|
+
- **Total**: 15 direct + 9 transitive = 24 query methods are RPC-first
|
|
26
|
+
|
|
27
|
+
### Coverage
|
|
28
|
+
| Module | RPC-First | Total |
|
|
29
|
+
|--------|-----------|-------|
|
|
30
|
+
| JS chain/queries.js | 22/22 | 100% |
|
|
31
|
+
| JS chain/fee-grants.js | 7/7 | 100% |
|
|
32
|
+
| JS session-manager.js | 1/1 | 100% |
|
|
33
|
+
| JS batch.js | 1/1 | 100% |
|
|
34
|
+
| C# ChainClient.Queries | 13/13 | 100% |
|
|
35
|
+
| C# ChainClient.FeeGrants | 2/2 | 100% |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
5
39
|
## Documentation Versions
|
|
6
40
|
|
|
7
41
|
| Version | Date | Changes |
|
package/batch.js
CHANGED
|
@@ -29,10 +29,9 @@ import { sleep } from './defaults.js';
|
|
|
29
29
|
import {
|
|
30
30
|
broadcast,
|
|
31
31
|
extractAllSessionIds,
|
|
32
|
-
findExistingSession,
|
|
33
|
-
lcdPaginatedSafe,
|
|
34
32
|
MSG_TYPES,
|
|
35
33
|
} from './cosmjs-setup.js';
|
|
34
|
+
import { findExistingSession, querySessions } from './chain/queries.js';
|
|
36
35
|
|
|
37
36
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
38
37
|
|
|
@@ -257,18 +256,15 @@ export async function waitForBatchSessions(nodeAddrs, walletAddr, lcdUrl, option
|
|
|
257
256
|
while (pending.size > 0 && Date.now() < deadline) {
|
|
258
257
|
await sleep(pollIntervalMs);
|
|
259
258
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
`/sentinel/session/v3/sessions?address=${walletAddr}&status=1`,
|
|
263
|
-
'sessions',
|
|
264
|
-
);
|
|
259
|
+
// RPC-first via chain/queries.js — returns flattened sessions
|
|
260
|
+
const result = await querySessions(walletAddr, baseLcd, { status: '1' });
|
|
265
261
|
for (const s of (result.items || [])) {
|
|
266
262
|
const bs = s.base_session || s;
|
|
267
263
|
const n = bs.node_address || bs.node;
|
|
268
264
|
if (pending.has(n)) pending.delete(n);
|
|
269
265
|
}
|
|
270
266
|
} catch {
|
|
271
|
-
// Transient LCD error — will retry on next poll
|
|
267
|
+
// Transient RPC/LCD error — will retry on next poll
|
|
272
268
|
}
|
|
273
269
|
}
|
|
274
270
|
|
package/chain/fee-grants.js
CHANGED
|
@@ -16,6 +16,12 @@ import { ValidationError, ErrorCodes } from '../errors.js';
|
|
|
16
16
|
import { lcd, lcdPaginatedSafe, lcdQueryAll } from './lcd.js';
|
|
17
17
|
import { isSameKey } from './wallet.js';
|
|
18
18
|
import { queryPlanSubscribers } from './queries.js';
|
|
19
|
+
import {
|
|
20
|
+
createRpcQueryClientWithFallback,
|
|
21
|
+
rpcQueryFeeGrant as _rpcQueryFeeGrant,
|
|
22
|
+
rpcQueryFeeGrants as _rpcQueryFeeGrants,
|
|
23
|
+
rpcQueryFeeGrantsIssued as _rpcQueryFeeGrantsIssued,
|
|
24
|
+
} from './rpc.js';
|
|
19
25
|
|
|
20
26
|
// ─── Protobuf Helpers for FeeGrant ──────────────────────────────────────────
|
|
21
27
|
// Uses the same manual protobuf encoding as Sentinel types — no codegen needed.
|
|
@@ -60,6 +66,21 @@ function encodeAny(typeUrl, valueBytes) {
|
|
|
60
66
|
]);
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
// ─── RPC Client Helper ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
let _rpcClient = null;
|
|
72
|
+
let _rpcClientPromise = null;
|
|
73
|
+
|
|
74
|
+
async function getRpcClient() {
|
|
75
|
+
if (_rpcClient) return _rpcClient;
|
|
76
|
+
if (_rpcClientPromise) return _rpcClientPromise;
|
|
77
|
+
_rpcClientPromise = createRpcQueryClientWithFallback()
|
|
78
|
+
.then(client => { _rpcClient = client; return client; })
|
|
79
|
+
.catch(() => { _rpcClient = null; return null; })
|
|
80
|
+
.finally(() => { _rpcClientPromise = null; });
|
|
81
|
+
return _rpcClientPromise;
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
// ─── FeeGrant (cosmos.feegrant.v1beta1) ─────────────────────────────────────
|
|
64
85
|
|
|
65
86
|
/**
|
|
@@ -105,29 +126,59 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
|
|
|
105
126
|
|
|
106
127
|
/**
|
|
107
128
|
* Query fee grants given to a grantee.
|
|
129
|
+
* RPC-first with LCD fallback.
|
|
108
130
|
* @returns {Promise<Array>} Array of allowance objects
|
|
109
131
|
*/
|
|
110
132
|
export async function queryFeeGrants(lcdUrl, grantee) {
|
|
133
|
+
// RPC-first
|
|
134
|
+
try {
|
|
135
|
+
const rpc = await getRpcClient();
|
|
136
|
+
if (rpc) {
|
|
137
|
+
return await _rpcQueryFeeGrants(rpc, grantee);
|
|
138
|
+
}
|
|
139
|
+
} catch { /* fall through to LCD */ }
|
|
140
|
+
|
|
141
|
+
// LCD fallback
|
|
111
142
|
const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`, 'allowances');
|
|
112
143
|
return items;
|
|
113
144
|
}
|
|
114
145
|
|
|
115
146
|
/**
|
|
116
147
|
* Query fee grants issued BY an address (where addr is the granter).
|
|
148
|
+
* RPC-first with LCD fallback.
|
|
117
149
|
* @param {string} lcdUrl
|
|
118
150
|
* @param {string} granter - Address that issued the grants
|
|
119
151
|
* @returns {Promise<Array>}
|
|
120
152
|
*/
|
|
121
153
|
export async function queryFeeGrantsIssued(lcdUrl, granter) {
|
|
154
|
+
// RPC-first
|
|
155
|
+
try {
|
|
156
|
+
const rpc = await getRpcClient();
|
|
157
|
+
if (rpc) {
|
|
158
|
+
return await _rpcQueryFeeGrantsIssued(rpc, granter);
|
|
159
|
+
}
|
|
160
|
+
} catch { /* fall through to LCD */ }
|
|
161
|
+
|
|
162
|
+
// LCD fallback
|
|
122
163
|
const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/issued/${granter}`, 'allowances');
|
|
123
164
|
return items;
|
|
124
165
|
}
|
|
125
166
|
|
|
126
167
|
/**
|
|
127
168
|
* Query a specific fee grant between granter and grantee.
|
|
169
|
+
* RPC-first with LCD fallback.
|
|
128
170
|
* @returns {Promise<object|null>} Allowance object or null
|
|
129
171
|
*/
|
|
130
172
|
export async function queryFeeGrant(lcdUrl, granter, grantee) {
|
|
173
|
+
// RPC-first
|
|
174
|
+
try {
|
|
175
|
+
const rpc = await getRpcClient();
|
|
176
|
+
if (rpc) {
|
|
177
|
+
return await _rpcQueryFeeGrant(rpc, granter, grantee);
|
|
178
|
+
}
|
|
179
|
+
} catch { /* fall through to LCD */ }
|
|
180
|
+
|
|
181
|
+
// LCD fallback
|
|
131
182
|
try {
|
|
132
183
|
const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
|
|
133
184
|
return data.allowance || null;
|
package/chain/queries.js
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
rpcQuerySubscriptionAllocations as rpcQuerySubAllocations,
|
|
31
31
|
rpcQueryPlan,
|
|
32
32
|
rpcQueryBalance,
|
|
33
|
+
rpcQueryProvider as _rpcQueryProvider,
|
|
34
|
+
rpcQueryAuthzGrants as _rpcQueryAuthzGrants,
|
|
33
35
|
} from './rpc.js';
|
|
34
36
|
|
|
35
37
|
// Re-export for convenience
|
|
@@ -674,7 +676,7 @@ export async function queryPlanNodes(planId, lcdUrl) {
|
|
|
674
676
|
|
|
675
677
|
/**
|
|
676
678
|
* Discover all available plans with metadata (subscriber count, node count, price).
|
|
677
|
-
*
|
|
679
|
+
* RPC-first: probes plan IDs via rpcQueryPlan, falls back to LCD.
|
|
678
680
|
*
|
|
679
681
|
* @param {string} [lcdUrl]
|
|
680
682
|
* @param {object} [opts]
|
|
@@ -690,19 +692,61 @@ export async function discoverPlans(lcdUrl, opts = {}) {
|
|
|
690
692
|
const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
|
|
691
693
|
const plans = [];
|
|
692
694
|
|
|
695
|
+
// Try to get RPC client for plan queries
|
|
696
|
+
let rpc = null;
|
|
697
|
+
try { rpc = await getRpcClient(); } catch { /* LCD only */ }
|
|
698
|
+
|
|
693
699
|
for (let batch = 0; batch < Math.ceil(maxId / batchSize); batch++) {
|
|
694
700
|
const probes = [];
|
|
695
701
|
for (let i = batch * batchSize + 1; i <= Math.min((batch + 1) * batchSize, maxId); i++) {
|
|
696
702
|
probes.push((async (id) => {
|
|
697
703
|
try {
|
|
698
|
-
|
|
699
|
-
|
|
704
|
+
// RPC-first: query plan existence via RPC
|
|
705
|
+
let plan = null;
|
|
706
|
+
if (rpc) {
|
|
707
|
+
try { plan = await rpcQueryPlan(rpc, id); } catch { /* fall through */ }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Get subscriber count — RPC for plan subs, LCD as fallback
|
|
711
|
+
let subCount = 0;
|
|
712
|
+
let price = null;
|
|
713
|
+
if (rpc) {
|
|
714
|
+
try {
|
|
715
|
+
const subs = await rpcQuerySubscriptionsForPlan(rpc, id, { limit: 1 });
|
|
716
|
+
subCount = subs.length; // Quick check — at least 1
|
|
717
|
+
price = subs[0]?.price || null;
|
|
718
|
+
// If RPC returned subs, plan exists even if rpcQueryPlan returned null
|
|
719
|
+
if (subCount > 0 && !plan) plan = { id };
|
|
720
|
+
} catch { /* fall through */ }
|
|
721
|
+
}
|
|
722
|
+
if (!plan && subCount === 0) {
|
|
723
|
+
// LCD fallback for subscriber count
|
|
724
|
+
try {
|
|
725
|
+
const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
|
|
726
|
+
subCount = parseInt(subData.pagination?.total || '0', 10);
|
|
727
|
+
price = subData.subscriptions?.[0]?.price || null;
|
|
728
|
+
if (subCount > 0) plan = { id };
|
|
729
|
+
} catch { /* plan doesn't exist */ }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!plan && !includeEmpty) return null;
|
|
700
733
|
if (subCount === 0 && !includeEmpty) return null;
|
|
701
|
-
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
734
|
+
|
|
735
|
+
// Get node count — RPC-first
|
|
736
|
+
let nodeCount = 0;
|
|
737
|
+
if (rpc) {
|
|
738
|
+
try {
|
|
739
|
+
const nodes = await rpcQueryNodesForPlan(rpc, id, { status: 1, limit: 5000 });
|
|
740
|
+
nodeCount = nodes.length;
|
|
741
|
+
} catch { /* fall through to LCD */ }
|
|
742
|
+
}
|
|
743
|
+
if (nodeCount === 0) {
|
|
744
|
+
try {
|
|
745
|
+
const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
|
|
746
|
+
nodeCount = (nodeData.nodes || []).length;
|
|
747
|
+
} catch { /* no nodes */ }
|
|
748
|
+
}
|
|
749
|
+
|
|
706
750
|
return { id, subscribers: subCount, nodeCount, price, hasNodes: nodeCount > 0 };
|
|
707
751
|
} catch { return null; }
|
|
708
752
|
})(i));
|
|
@@ -716,18 +760,51 @@ export async function discoverPlans(lcdUrl, opts = {}) {
|
|
|
716
760
|
|
|
717
761
|
/**
|
|
718
762
|
* Get provider details by address.
|
|
763
|
+
* RPC-first with LCD fallback. Provider is still v2 on chain.
|
|
719
764
|
* @param {string} provAddress - sentprov1... address
|
|
720
765
|
* @param {object} [opts]
|
|
721
766
|
* @param {string} [opts.lcdUrl]
|
|
722
767
|
* @returns {Promise<object|null>}
|
|
723
768
|
*/
|
|
724
769
|
export async function getProviderByAddress(provAddress, opts = {}) {
|
|
770
|
+
// RPC-first
|
|
771
|
+
try {
|
|
772
|
+
const rpc = await getRpcClient();
|
|
773
|
+
if (rpc) {
|
|
774
|
+
const provider = await _rpcQueryProvider(rpc, provAddress);
|
|
775
|
+
if (provider) return provider;
|
|
776
|
+
}
|
|
777
|
+
} catch { /* fall through to LCD */ }
|
|
778
|
+
|
|
779
|
+
// LCD fallback
|
|
725
780
|
try {
|
|
726
781
|
const data = await lcdQuery(`/sentinel/provider/v2/providers/${provAddress}`, opts);
|
|
727
782
|
return data.provider || null;
|
|
728
783
|
} catch { return null; }
|
|
729
784
|
}
|
|
730
785
|
|
|
786
|
+
/**
|
|
787
|
+
* Query authz grants between granter and grantee.
|
|
788
|
+
* RPC-first with LCD fallback.
|
|
789
|
+
* @param {string} lcdUrl - LCD endpoint
|
|
790
|
+
* @param {string} granter - Granter address (sent1...)
|
|
791
|
+
* @param {string} grantee - Grantee address (sent1...)
|
|
792
|
+
* @returns {Promise<Array>}
|
|
793
|
+
*/
|
|
794
|
+
export async function queryAuthzGrants(lcdUrl, granter, grantee) {
|
|
795
|
+
// RPC-first
|
|
796
|
+
try {
|
|
797
|
+
const rpc = await getRpcClient();
|
|
798
|
+
if (rpc) {
|
|
799
|
+
return await _rpcQueryAuthzGrants(rpc, granter, grantee);
|
|
800
|
+
}
|
|
801
|
+
} catch { /* fall through to LCD */ }
|
|
802
|
+
|
|
803
|
+
// LCD fallback
|
|
804
|
+
const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`, 'grants');
|
|
805
|
+
return items;
|
|
806
|
+
}
|
|
807
|
+
|
|
731
808
|
// ─── VPN Settings Persistence ────────────────────────────────────────────────
|
|
732
809
|
// v27: Persistent user settings (backported from C# VpnSettings.cs).
|
|
733
810
|
// Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.
|
package/chain/rpc.js
CHANGED
|
@@ -722,6 +722,175 @@ function _decodeBasicAllowance(bytes) {
|
|
|
722
722
|
return { spend_limit: spendLimit, expiration };
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Query all fee grants for a grantee via RPC.
|
|
727
|
+
* Returns array of grant objects matching LCD format.
|
|
728
|
+
*
|
|
729
|
+
* @param {{ queryClient: QueryClient }} client - From createRpcQueryClient()
|
|
730
|
+
* @param {string} grantee - Grantee address (sent1...)
|
|
731
|
+
* @param {{ limit?: number }} [opts]
|
|
732
|
+
* @returns {Promise<Array<{ granter: string, grantee: string, allowance: object }>>}
|
|
733
|
+
*/
|
|
734
|
+
export async function rpcQueryFeeGrants(client, grantee, { limit = 100 } = {}) {
|
|
735
|
+
const path = '/cosmos.feegrant.v1beta1.Query/Allowances';
|
|
736
|
+
const request = concat([
|
|
737
|
+
encodeString(1, grantee),
|
|
738
|
+
encodeEmbedded(2, encodePagination({ limit })),
|
|
739
|
+
]);
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
const response = await abciQuery(client.queryClient, path, request);
|
|
743
|
+
const fields = decodeProto(new Uint8Array(response));
|
|
744
|
+
// Field 1 = repeated Grant (granter, grantee, allowance)
|
|
745
|
+
return (fields[1] || []).map(entry => _decodeFeeGrant(entry.value, grantee));
|
|
746
|
+
} catch {
|
|
747
|
+
return [];
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Query all fee grants issued BY a granter via RPC.
|
|
753
|
+
*
|
|
754
|
+
* @param {{ queryClient: QueryClient }} client
|
|
755
|
+
* @param {string} granter - Granter address (sent1...)
|
|
756
|
+
* @param {{ limit?: number }} [opts]
|
|
757
|
+
* @returns {Promise<Array<{ granter: string, grantee: string, allowance: object }>>}
|
|
758
|
+
*/
|
|
759
|
+
export async function rpcQueryFeeGrantsIssued(client, granter, { limit = 100 } = {}) {
|
|
760
|
+
const path = '/cosmos.feegrant.v1beta1.Query/AllowancesByGranter';
|
|
761
|
+
const request = concat([
|
|
762
|
+
encodeString(1, granter),
|
|
763
|
+
encodeEmbedded(2, encodePagination({ limit })),
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
const response = await abciQuery(client.queryClient, path, request);
|
|
768
|
+
const fields = decodeProto(new Uint8Array(response));
|
|
769
|
+
// Field 1 = repeated Grant
|
|
770
|
+
return (fields[1] || []).map(entry => _decodeFeeGrant(entry.value, null, granter));
|
|
771
|
+
} catch {
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Decode a cosmos.feegrant.v1beta1.Grant proto.
|
|
778
|
+
* Fields: 1=granter, 2=grantee, 3=allowance (Any)
|
|
779
|
+
*/
|
|
780
|
+
function _decodeFeeGrant(bytes, defaultGrantee, defaultGranter) {
|
|
781
|
+
const grantFields = decodeProto(bytes);
|
|
782
|
+
const result = {
|
|
783
|
+
granter: grantFields[1]?.[0] ? decodeString(grantFields[1][0].value) : (defaultGranter || ''),
|
|
784
|
+
grantee: grantFields[2]?.[0] ? decodeString(grantFields[2][0].value) : (defaultGrantee || ''),
|
|
785
|
+
allowance: null,
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
if (!grantFields[3]?.[0]) return result;
|
|
789
|
+
const anyFields = decodeProto(grantFields[3][0].value);
|
|
790
|
+
const typeUrl = anyFields[1]?.[0] ? decodeString(anyFields[1][0].value) : '';
|
|
791
|
+
const innerBytes = anyFields[2]?.[0]?.value;
|
|
792
|
+
|
|
793
|
+
if (typeUrl.includes('AllowedMsgAllowance') && innerBytes) {
|
|
794
|
+
const amFields = decodeProto(innerBytes);
|
|
795
|
+
const allowedMessages = (amFields[2] || []).map(f => decodeString(f.value));
|
|
796
|
+
let innerAllowance = null;
|
|
797
|
+
if (amFields[1]?.[0]) {
|
|
798
|
+
const innerAnyFields = decodeProto(amFields[1][0].value);
|
|
799
|
+
const innerTypeUrl = innerAnyFields[1]?.[0] ? decodeString(innerAnyFields[1][0].value) : '';
|
|
800
|
+
const basicBytes = innerAnyFields[2]?.[0]?.value;
|
|
801
|
+
if (basicBytes) {
|
|
802
|
+
innerAllowance = _decodeBasicAllowance(basicBytes);
|
|
803
|
+
innerAllowance['@type'] = innerTypeUrl;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
result.allowance = { '@type': typeUrl, allowance: innerAllowance, allowed_messages: allowedMessages };
|
|
807
|
+
} else if (typeUrl.includes('BasicAllowance') && innerBytes) {
|
|
808
|
+
result.allowance = _decodeBasicAllowance(innerBytes);
|
|
809
|
+
result.allowance['@type'] = typeUrl;
|
|
810
|
+
} else {
|
|
811
|
+
result.allowance = { '@type': typeUrl };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Query authz grants between granter and grantee via RPC.
|
|
819
|
+
*
|
|
820
|
+
* @param {{ queryClient: QueryClient }} client
|
|
821
|
+
* @param {string} granter - Granter address (sent1...)
|
|
822
|
+
* @param {string} grantee - Grantee address (sent1...)
|
|
823
|
+
* @param {{ msgTypeUrl?: string, limit?: number }} [opts]
|
|
824
|
+
* @returns {Promise<Array<{ granter: string, grantee: string, authorization: object, expiration: string|null }>>}
|
|
825
|
+
*/
|
|
826
|
+
export async function rpcQueryAuthzGrants(client, granter, grantee, { msgTypeUrl, limit = 100 } = {}) {
|
|
827
|
+
const path = '/cosmos.authz.v1beta1.Query/Grants';
|
|
828
|
+
const parts = [
|
|
829
|
+
encodeString(1, granter),
|
|
830
|
+
encodeString(2, grantee),
|
|
831
|
+
];
|
|
832
|
+
if (msgTypeUrl) parts.push(encodeString(3, msgTypeUrl));
|
|
833
|
+
parts.push(encodeEmbedded(4, encodePagination({ limit })));
|
|
834
|
+
const request = concat(parts);
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
const response = await abciQuery(client.queryClient, path, request);
|
|
838
|
+
const fields = decodeProto(new Uint8Array(response));
|
|
839
|
+
// Field 1 = repeated GrantAuthorization
|
|
840
|
+
return (fields[1] || []).map(entry => {
|
|
841
|
+
const gf = decodeProto(entry.value);
|
|
842
|
+
const result = {
|
|
843
|
+
granter: gf[1]?.[0] ? decodeString(gf[1][0].value) : granter,
|
|
844
|
+
grantee: gf[2]?.[0] ? decodeString(gf[2][0].value) : grantee,
|
|
845
|
+
authorization: null,
|
|
846
|
+
expiration: gf[4]?.[0] ? decodeTimestamp(gf[4][0].value) : null,
|
|
847
|
+
};
|
|
848
|
+
// Field 3 = authorization (Any)
|
|
849
|
+
if (gf[3]?.[0]) {
|
|
850
|
+
const anyF = decodeProto(gf[3][0].value);
|
|
851
|
+
result.authorization = {
|
|
852
|
+
'@type': anyF[1]?.[0] ? decodeString(anyF[1][0].value) : '',
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return result;
|
|
856
|
+
});
|
|
857
|
+
} catch {
|
|
858
|
+
return [];
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Query a provider by address via RPC.
|
|
864
|
+
* Provider is still v2 on chain (NOT v3).
|
|
865
|
+
*
|
|
866
|
+
* @param {{ queryClient: QueryClient }} client
|
|
867
|
+
* @param {string} provAddress - sentprov1... address
|
|
868
|
+
* @returns {Promise<object|null>} Provider object or null
|
|
869
|
+
*/
|
|
870
|
+
export async function rpcQueryProvider(client, provAddress) {
|
|
871
|
+
const path = '/sentinel.provider.v2.QueryService/QueryProvider';
|
|
872
|
+
const request = encodeString(1, provAddress);
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
const response = await abciQuery(client.queryClient, path, request);
|
|
876
|
+
const fields = decodeProto(new Uint8Array(response));
|
|
877
|
+
// Field 1 = Provider
|
|
878
|
+
if (!fields[1]?.[0]) return null;
|
|
879
|
+
const pf = decodeProto(fields[1][0].value);
|
|
880
|
+
return {
|
|
881
|
+
address: pf[1]?.[0] ? decodeString(pf[1][0].value) : provAddress,
|
|
882
|
+
name: pf[2]?.[0] ? decodeString(pf[2][0].value) : '',
|
|
883
|
+
identity: pf[3]?.[0] ? decodeString(pf[3][0].value) : '',
|
|
884
|
+
website: pf[4]?.[0] ? decodeString(pf[4][0].value) : '',
|
|
885
|
+
description: pf[5]?.[0] ? decodeString(pf[5][0].value) : '',
|
|
886
|
+
status: pf[6]?.[0] ? Number(pf[6][0].value) : 0,
|
|
887
|
+
status_at: pf[7]?.[0] ? decodeTimestamp(pf[7][0].value) : null,
|
|
888
|
+
};
|
|
889
|
+
} catch {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
725
894
|
export async function rpcQueryBalance(client, address, denom = 'udvpn') {
|
|
726
895
|
const path = '/cosmos.bank.v1beta1.Query/Balance';
|
|
727
896
|
const request = concat([
|
package/cosmjs-setup.js
CHANGED
|
@@ -52,9 +52,42 @@ import {
|
|
|
52
52
|
} from './plan-operations.js';
|
|
53
53
|
import { GAS_PRICE, RPC_ENDPOINTS, LCD_ENDPOINTS, tryWithFallback } from './defaults.js';
|
|
54
54
|
import { ValidationError, NodeError, ChainError, ErrorCodes } from './errors.js';
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// path, os, fs imports removed — settings persistence now in chain/queries.js
|
|
56
|
+
|
|
57
|
+
// RPC-first query layer — cosmjs-setup.js delegates query functions here
|
|
58
|
+
import {
|
|
59
|
+
findExistingSession as _findExistingSession,
|
|
60
|
+
fetchActiveNodes as _fetchActiveNodes,
|
|
61
|
+
getNetworkOverview as _getNetworkOverview,
|
|
62
|
+
queryNode as _queryNode,
|
|
63
|
+
resolveNodeUrl as _resolveNodeUrl,
|
|
64
|
+
querySubscriptions as _querySubscriptions,
|
|
65
|
+
querySessionAllocation as _querySessionAllocation,
|
|
66
|
+
querySessions as _querySessions,
|
|
67
|
+
flattenSession as _flattenSession,
|
|
68
|
+
querySubscription as _querySubscription,
|
|
69
|
+
hasActiveSubscription as _hasActiveSubscription,
|
|
70
|
+
queryPlanNodes as _queryPlanNodes,
|
|
71
|
+
discoverPlans as _discoverPlans,
|
|
72
|
+
discoverPlanIds as _discoverPlanIds,
|
|
73
|
+
getNodePrices as _getNodePrices,
|
|
74
|
+
getProviderByAddress as _getProviderByAddress,
|
|
75
|
+
queryPlanSubscribers as _queryPlanSubscribers,
|
|
76
|
+
getPlanStats as _getPlanStats,
|
|
77
|
+
querySubscriptionAllocations as _querySubscriptionAllocations,
|
|
78
|
+
queryAuthzGrants as _queryAuthzGrants,
|
|
79
|
+
loadVpnSettings as _loadVpnSettings,
|
|
80
|
+
saveVpnSettings as _saveVpnSettings,
|
|
81
|
+
} from './chain/queries.js';
|
|
82
|
+
import {
|
|
83
|
+
queryFeeGrants as _queryFeeGrants,
|
|
84
|
+
queryFeeGrantsIssued as _queryFeeGrantsIssued,
|
|
85
|
+
queryFeeGrant as _queryFeeGrant,
|
|
86
|
+
grantPlanSubscribers as _grantPlanSubscribers,
|
|
87
|
+
getExpiringGrants as _getExpiringGrants,
|
|
88
|
+
renewExpiringGrants as _renewExpiringGrants,
|
|
89
|
+
monitorFeeGrants as _monitorFeeGrants,
|
|
90
|
+
} from './chain/fee-grants.js';
|
|
58
91
|
|
|
59
92
|
// ─── Input Validation Helpers ────────────────────────────────────────────────
|
|
60
93
|
|
|
@@ -472,18 +505,7 @@ export async function getBalance(client, address) {
|
|
|
472
505
|
* Note: Sessions have a nested base_session object containing the actual data.
|
|
473
506
|
*/
|
|
474
507
|
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
475
|
-
|
|
476
|
-
for (const s of items) {
|
|
477
|
-
const bs = s.base_session || s;
|
|
478
|
-
if ((bs.node_address || bs.node) !== nodeAddr) continue;
|
|
479
|
-
if (bs.status && bs.status !== 'active') continue;
|
|
480
|
-
const acct = bs.acc_address || bs.address;
|
|
481
|
-
if (acct && acct !== walletAddr) continue;
|
|
482
|
-
const maxBytes = parseInt(bs.max_bytes || '0');
|
|
483
|
-
const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
|
|
484
|
-
if (maxBytes === 0 || used < maxBytes) return BigInt(bs.id);
|
|
485
|
-
}
|
|
486
|
-
return null;
|
|
508
|
+
return _findExistingSession(lcdUrl, walletAddr, nodeAddr);
|
|
487
509
|
}
|
|
488
510
|
|
|
489
511
|
/**
|
|
@@ -493,13 +515,7 @@ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
|
493
515
|
* This handles both formats.
|
|
494
516
|
*/
|
|
495
517
|
export function resolveNodeUrl(node) {
|
|
496
|
-
|
|
497
|
-
if (node.remote_url && typeof node.remote_url === 'string') return node.remote_url;
|
|
498
|
-
// v3 LCD: remote_addrs is an array of "IP:PORT" strings
|
|
499
|
-
const addrs = node.remote_addrs || [];
|
|
500
|
-
const raw = addrs.find(a => a.includes(':')) || addrs[0];
|
|
501
|
-
if (!raw) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${node.address} has no remote_addrs`, { address: node.address });
|
|
502
|
-
return raw.startsWith('http') ? raw : `https://${raw}`;
|
|
518
|
+
return _resolveNodeUrl(node);
|
|
503
519
|
}
|
|
504
520
|
|
|
505
521
|
/**
|
|
@@ -507,11 +523,7 @@ export function resolveNodeUrl(node) {
|
|
|
507
523
|
* Returns array of node objects. Each node has `remote_url` resolved from `remote_addrs`.
|
|
508
524
|
*/
|
|
509
525
|
export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
|
|
510
|
-
|
|
511
|
-
for (const n of items) {
|
|
512
|
-
try { n.remote_url = resolveNodeUrl(n); } catch { /* skip nodes with no address */ }
|
|
513
|
-
}
|
|
514
|
-
return items;
|
|
526
|
+
return _fetchActiveNodes(lcdUrl, limit, maxPages);
|
|
515
527
|
}
|
|
516
528
|
|
|
517
529
|
/**
|
|
@@ -527,55 +539,7 @@ export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
|
|
|
527
539
|
* console.log(`Average: ${overview.averagePrice.gigabyteDvpn.toFixed(3)} P2P/GB`);
|
|
528
540
|
*/
|
|
529
541
|
export async function getNetworkOverview(lcdUrl) {
|
|
530
|
-
|
|
531
|
-
if (lcdUrl) {
|
|
532
|
-
nodes = await fetchActiveNodes(lcdUrl);
|
|
533
|
-
} else {
|
|
534
|
-
const result = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, 'getNetworkOverview');
|
|
535
|
-
nodes = result.result;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Filter to nodes that accept udvpn
|
|
539
|
-
const active = nodes.filter(n => n.remote_url && (n.gigabyte_prices || []).some(p => p.denom === 'udvpn'));
|
|
540
|
-
|
|
541
|
-
// Count by country (from LCD metadata, limited — enrichNodes gives better data)
|
|
542
|
-
const countryMap = {};
|
|
543
|
-
for (const n of active) {
|
|
544
|
-
const c = n.location?.country || n.country || 'Unknown';
|
|
545
|
-
countryMap[c] = (countryMap[c] || 0) + 1;
|
|
546
|
-
}
|
|
547
|
-
const byCountry = Object.entries(countryMap)
|
|
548
|
-
.map(([country, count]) => ({ country, count }))
|
|
549
|
-
.sort((a, b) => b.count - a.count);
|
|
550
|
-
|
|
551
|
-
// Count by type (type not in LCD — estimate from service_type field if present)
|
|
552
|
-
const byType = { wireguard: 0, v2ray: 0, unknown: 0 };
|
|
553
|
-
for (const n of active) {
|
|
554
|
-
const t = n.service_type || n.type;
|
|
555
|
-
if (t === 'wireguard' || t === 1) byType.wireguard++;
|
|
556
|
-
else if (t === 'v2ray' || t === 2) byType.v2ray++;
|
|
557
|
-
else byType.unknown++;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Average prices
|
|
561
|
-
let gbTotal = 0, gbCount = 0, hrTotal = 0, hrCount = 0;
|
|
562
|
-
for (const n of active) {
|
|
563
|
-
const gb = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
|
|
564
|
-
if (gb?.quote_value) { gbTotal += parseInt(gb.quote_value, 10); gbCount++; }
|
|
565
|
-
const hr = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
|
|
566
|
-
if (hr?.quote_value) { hrTotal += parseInt(hr.quote_value, 10); hrCount++; }
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
return {
|
|
570
|
-
totalNodes: active.length,
|
|
571
|
-
byCountry,
|
|
572
|
-
byType,
|
|
573
|
-
averagePrice: {
|
|
574
|
-
gigabyteDvpn: gbCount > 0 ? (gbTotal / gbCount) / 1_000_000 : 0,
|
|
575
|
-
hourlyDvpn: hrCount > 0 ? (hrTotal / hrCount) / 1_000_000 : 0,
|
|
576
|
-
},
|
|
577
|
-
nodes: active,
|
|
578
|
-
};
|
|
542
|
+
return _getNetworkOverview(lcdUrl);
|
|
579
543
|
}
|
|
580
544
|
|
|
581
545
|
/**
|
|
@@ -584,9 +548,7 @@ export async function getNetworkOverview(lcdUrl) {
|
|
|
584
548
|
* Returns sorted array of plan IDs that have at least 1 subscription.
|
|
585
549
|
*/
|
|
586
550
|
export async function discoverPlanIds(lcdUrl, maxId = 500) {
|
|
587
|
-
|
|
588
|
-
const plans = await discoverPlans(lcdUrl, { maxId });
|
|
589
|
-
return plans.map(p => p.id);
|
|
551
|
+
return _discoverPlanIds(lcdUrl, maxId);
|
|
590
552
|
}
|
|
591
553
|
|
|
592
554
|
/**
|
|
@@ -606,29 +568,7 @@ export async function discoverPlanIds(lcdUrl, maxId = 500) {
|
|
|
606
568
|
* // needed by encodeMsgStartSession's max_price field.
|
|
607
569
|
*/
|
|
608
570
|
export async function getNodePrices(nodeAddress, lcdUrl) {
|
|
609
|
-
|
|
610
|
-
throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (46 characters)', { value: nodeAddress });
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Reuse queryNode() instead of duplicating pagination
|
|
614
|
-
const node = await queryNode(nodeAddress, { lcdUrl });
|
|
615
|
-
|
|
616
|
-
function extractPrice(priceArray) {
|
|
617
|
-
if (!Array.isArray(priceArray)) return { dvpn: 0, udvpn: 0, raw: null };
|
|
618
|
-
const entry = priceArray.find(p => p.denom === 'udvpn');
|
|
619
|
-
if (!entry) return { dvpn: 0, udvpn: 0, raw: null };
|
|
620
|
-
// Defensive fallback chain: quote_value (V3 current) → base_value → amount (legacy)
|
|
621
|
-
const rawVal = entry.quote_value || entry.base_value || entry.amount || '0';
|
|
622
|
-
const udvpn = parseInt(rawVal, 10) || 0;
|
|
623
|
-
return { dvpn: parseFloat((udvpn / 1_000_000).toFixed(6)), udvpn, raw: entry };
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
return {
|
|
627
|
-
gigabyte: extractPrice(node.gigabyte_prices),
|
|
628
|
-
hourly: extractPrice(node.hourly_prices),
|
|
629
|
-
denom: 'P2P',
|
|
630
|
-
nodeAddress,
|
|
631
|
-
};
|
|
571
|
+
return _getNodePrices(nodeAddress, lcdUrl);
|
|
632
572
|
}
|
|
633
573
|
|
|
634
574
|
// ─── Display & Serialization Helpers ────────────────────────────────────────
|
|
@@ -861,8 +801,7 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
|
|
|
861
801
|
* @returns {Promise<Array>} Array of allowance objects
|
|
862
802
|
*/
|
|
863
803
|
export async function queryFeeGrants(lcdUrl, grantee) {
|
|
864
|
-
|
|
865
|
-
return items;
|
|
804
|
+
return _queryFeeGrants(lcdUrl, grantee);
|
|
866
805
|
}
|
|
867
806
|
|
|
868
807
|
/**
|
|
@@ -872,8 +811,7 @@ export async function queryFeeGrants(lcdUrl, grantee) {
|
|
|
872
811
|
* @returns {Promise<Array>}
|
|
873
812
|
*/
|
|
874
813
|
export async function queryFeeGrantsIssued(lcdUrl, granter) {
|
|
875
|
-
|
|
876
|
-
return items;
|
|
814
|
+
return _queryFeeGrantsIssued(lcdUrl, granter);
|
|
877
815
|
}
|
|
878
816
|
|
|
879
817
|
/**
|
|
@@ -881,10 +819,7 @@ export async function queryFeeGrantsIssued(lcdUrl, granter) {
|
|
|
881
819
|
* @returns {Promise<object|null>} Allowance object or null
|
|
882
820
|
*/
|
|
883
821
|
export async function queryFeeGrant(lcdUrl, granter, grantee) {
|
|
884
|
-
|
|
885
|
-
const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
|
|
886
|
-
return data.allowance || null;
|
|
887
|
-
} catch { return null; } // 404 = no grant
|
|
822
|
+
return _queryFeeGrant(lcdUrl, granter, grantee);
|
|
888
823
|
}
|
|
889
824
|
|
|
890
825
|
/**
|
|
@@ -995,8 +930,7 @@ export function encodeForExec(msgs) {
|
|
|
995
930
|
* @returns {Promise<Array>} Array of grant objects
|
|
996
931
|
*/
|
|
997
932
|
export async function queryAuthzGrants(lcdUrl, granter, grantee) {
|
|
998
|
-
|
|
999
|
-
return items;
|
|
933
|
+
return _queryAuthzGrants(lcdUrl, granter, grantee);
|
|
1000
934
|
}
|
|
1001
935
|
|
|
1002
936
|
// ─── LCD Query Helpers (v25b) ────────────────────────────────────────────────
|
|
@@ -1096,20 +1030,7 @@ export async function lcdQueryAll(basePath, opts = {}) {
|
|
|
1096
1030
|
* @returns {Promise<{ subscribers: Array<{ address: string, status: number, id: string }>, total: number|null }>}
|
|
1097
1031
|
*/
|
|
1098
1032
|
export async function queryPlanSubscribers(planId, opts = {}) {
|
|
1099
|
-
|
|
1100
|
-
`/sentinel/subscription/v3/plans/${planId}/subscriptions`,
|
|
1101
|
-
{ lcdUrl: opts.lcdUrl, dataKey: 'subscriptions' },
|
|
1102
|
-
);
|
|
1103
|
-
let subscribers = items.map(s => ({
|
|
1104
|
-
address: s.address || s.subscriber,
|
|
1105
|
-
status: s.status,
|
|
1106
|
-
id: s.id || s.base_id,
|
|
1107
|
-
...s,
|
|
1108
|
-
}));
|
|
1109
|
-
if (opts.excludeAddress) {
|
|
1110
|
-
subscribers = subscribers.filter(s => s.address !== opts.excludeAddress);
|
|
1111
|
-
}
|
|
1112
|
-
return { subscribers, total };
|
|
1033
|
+
return _queryPlanSubscribers(planId, opts);
|
|
1113
1034
|
}
|
|
1114
1035
|
|
|
1115
1036
|
/**
|
|
@@ -1122,14 +1043,7 @@ export async function queryPlanSubscribers(planId, opts = {}) {
|
|
|
1122
1043
|
* @returns {Promise<{ subscriberCount: number, totalOnChain: number, ownerSubscribed: boolean }>}
|
|
1123
1044
|
*/
|
|
1124
1045
|
export async function getPlanStats(planId, ownerAddress, opts = {}) {
|
|
1125
|
-
|
|
1126
|
-
const ownerSubscribed = subscribers.some(s => s.address === ownerAddress);
|
|
1127
|
-
const filtered = subscribers.filter(s => s.address !== ownerAddress);
|
|
1128
|
-
return {
|
|
1129
|
-
subscriberCount: filtered.length,
|
|
1130
|
-
totalOnChain: total,
|
|
1131
|
-
ownerSubscribed,
|
|
1132
|
-
};
|
|
1046
|
+
return _getPlanStats(planId, ownerAddress, opts);
|
|
1133
1047
|
}
|
|
1134
1048
|
|
|
1135
1049
|
// ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
|
|
@@ -1146,39 +1060,7 @@ export async function getPlanStats(planId, ownerAddress, opts = {}) {
|
|
|
1146
1060
|
* @returns {Promise<{ msgs: Array, skipped: string[], newGrants: string[] }>} Messages ready for broadcast
|
|
1147
1061
|
*/
|
|
1148
1062
|
export async function grantPlanSubscribers(planId, opts = {}) {
|
|
1149
|
-
|
|
1150
|
-
if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
|
|
1151
|
-
|
|
1152
|
-
// Get subscribers
|
|
1153
|
-
const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
|
|
1154
|
-
|
|
1155
|
-
// Get existing grants ISSUED BY granter (not grants received)
|
|
1156
|
-
const existingGrants = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
|
|
1157
|
-
const alreadyGranted = new Set(existingGrants.map(g => g.grantee));
|
|
1158
|
-
|
|
1159
|
-
const msgs = [];
|
|
1160
|
-
const skipped = [];
|
|
1161
|
-
const newGrants = [];
|
|
1162
|
-
|
|
1163
|
-
const now = new Date();
|
|
1164
|
-
// Deduplicate by address and filter active+non-expired
|
|
1165
|
-
const seen = new Set();
|
|
1166
|
-
for (const sub of subscribers) {
|
|
1167
|
-
const addr = sub.acc_address || sub.address;
|
|
1168
|
-
if (!addr || seen.has(addr)) continue;
|
|
1169
|
-
seen.add(addr);
|
|
1170
|
-
// Skip self-grant (chain rejects granter === grantee)
|
|
1171
|
-
if (addr === granterAddress || isSameKey(addr, granterAddress)) { skipped.push(addr); continue; }
|
|
1172
|
-
// Skip inactive or expired
|
|
1173
|
-
if (sub.status && sub.status !== 'active') { skipped.push(addr); continue; }
|
|
1174
|
-
if (sub.inactive_at && new Date(sub.inactive_at) <= now) { skipped.push(addr); continue; }
|
|
1175
|
-
// Skip already granted
|
|
1176
|
-
if (alreadyGranted.has(addr)) { skipped.push(addr); continue; }
|
|
1177
|
-
msgs.push(buildFeeGrantMsg(granterAddress, addr, grantOpts));
|
|
1178
|
-
newGrants.push(addr);
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
return { msgs, skipped, newGrants };
|
|
1063
|
+
return _grantPlanSubscribers(planId, opts);
|
|
1182
1064
|
}
|
|
1183
1065
|
|
|
1184
1066
|
/**
|
|
@@ -1191,34 +1073,7 @@ export async function grantPlanSubscribers(planId, opts = {}) {
|
|
|
1191
1073
|
* @returns {Promise<Array<{ granter: string, grantee: string, expiresAt: Date|null, daysLeft: number|null }>>}
|
|
1192
1074
|
*/
|
|
1193
1075
|
export async function getExpiringGrants(lcdUrl, granteeOrGranter, withinDays = 7, role = 'grantee') {
|
|
1194
|
-
|
|
1195
|
-
? await queryFeeGrants(lcdUrl, granteeOrGranter)
|
|
1196
|
-
: await queryFeeGrantsIssued(lcdUrl, granteeOrGranter);
|
|
1197
|
-
|
|
1198
|
-
const now = Date.now();
|
|
1199
|
-
const cutoff = now + withinDays * 24 * 60 * 60_000;
|
|
1200
|
-
const expiring = [];
|
|
1201
|
-
|
|
1202
|
-
for (const g of grants) {
|
|
1203
|
-
// Fee grant allowances have complex nested @type structures:
|
|
1204
|
-
// BasicAllowance: { expiration }
|
|
1205
|
-
// PeriodicAllowance: { basic: { expiration } }
|
|
1206
|
-
// AllowedMsgAllowance: { allowance: { expiration } or allowance: { basic: { expiration } } }
|
|
1207
|
-
const a = g.allowance || {};
|
|
1208
|
-
const inner = a.allowance || a; // unwrap AllowedMsgAllowance
|
|
1209
|
-
const expStr = inner.expiration || inner.basic?.expiration || a.expiration || a.basic?.expiration;
|
|
1210
|
-
if (!expStr) continue; // no expiry set
|
|
1211
|
-
const expiresAt = new Date(expStr);
|
|
1212
|
-
if (expiresAt.getTime() <= cutoff) {
|
|
1213
|
-
expiring.push({
|
|
1214
|
-
granter: g.granter,
|
|
1215
|
-
grantee: g.grantee,
|
|
1216
|
-
expiresAt,
|
|
1217
|
-
daysLeft: Math.max(0, Math.round((expiresAt.getTime() - now) / (24 * 60 * 60_000))),
|
|
1218
|
-
});
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
return expiring;
|
|
1076
|
+
return _getExpiringGrants(lcdUrl, granteeOrGranter, withinDays, role);
|
|
1222
1077
|
}
|
|
1223
1078
|
|
|
1224
1079
|
/**
|
|
@@ -1231,18 +1086,7 @@ export async function getExpiringGrants(lcdUrl, granteeOrGranter, withinDays = 7
|
|
|
1231
1086
|
* @returns {Promise<{ msgs: Array, renewed: string[] }>} Messages ready for broadcast
|
|
1232
1087
|
*/
|
|
1233
1088
|
export async function renewExpiringGrants(lcdUrl, granterAddress, withinDays = 7, grantOpts = {}) {
|
|
1234
|
-
|
|
1235
|
-
const msgs = [];
|
|
1236
|
-
const renewed = [];
|
|
1237
|
-
|
|
1238
|
-
for (const g of expiring) {
|
|
1239
|
-
if (g.grantee === granterAddress) continue; // skip self
|
|
1240
|
-
msgs.push(buildRevokeFeeGrantMsg(granterAddress, g.grantee));
|
|
1241
|
-
msgs.push(buildFeeGrantMsg(granterAddress, g.grantee, grantOpts));
|
|
1242
|
-
renewed.push(g.grantee);
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
return { msgs, renewed };
|
|
1089
|
+
return _renewExpiringGrants(lcdUrl, granterAddress, withinDays, grantOpts);
|
|
1246
1090
|
}
|
|
1247
1091
|
|
|
1248
1092
|
// ─── Fee Grant Monitoring (v25b) ─────────────────────────────────────────────
|
|
@@ -1260,46 +1104,7 @@ export async function renewExpiringGrants(lcdUrl, granterAddress, withinDays = 7
|
|
|
1260
1104
|
* @returns {EventEmitter} Emits 'expiring' and 'expired' events. Call .stop() to stop monitoring.
|
|
1261
1105
|
*/
|
|
1262
1106
|
export function monitorFeeGrants(opts = {}) {
|
|
1263
|
-
|
|
1264
|
-
if (!lcdUrl || !address) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'monitorFeeGrants requires lcdUrl and address');
|
|
1265
|
-
|
|
1266
|
-
const emitter = new EventEmitter();
|
|
1267
|
-
let timer = null;
|
|
1268
|
-
|
|
1269
|
-
const check = async () => {
|
|
1270
|
-
try {
|
|
1271
|
-
const expiring = await getExpiringGrants(lcdUrl, address, warnDays, 'granter');
|
|
1272
|
-
const now = Date.now();
|
|
1273
|
-
|
|
1274
|
-
for (const g of expiring) {
|
|
1275
|
-
if (g.expiresAt.getTime() <= now) {
|
|
1276
|
-
emitter.emit('expired', g);
|
|
1277
|
-
} else {
|
|
1278
|
-
emitter.emit('expiring', g);
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
if (autoRenew && expiring.length > 0) {
|
|
1283
|
-
const { msgs, renewed } = await renewExpiringGrants(lcdUrl, address, warnDays, grantOpts);
|
|
1284
|
-
if (msgs.length > 0) {
|
|
1285
|
-
emitter.emit('renew', { msgs, renewed });
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
} catch (err) {
|
|
1289
|
-
emitter.emit('error', err);
|
|
1290
|
-
}
|
|
1291
|
-
};
|
|
1292
|
-
|
|
1293
|
-
// Start checking
|
|
1294
|
-
check();
|
|
1295
|
-
timer = setInterval(check, checkIntervalMs);
|
|
1296
|
-
if (timer.unref) timer.unref(); // Don't prevent process exit
|
|
1297
|
-
|
|
1298
|
-
emitter.stop = () => {
|
|
1299
|
-
if (timer) { clearInterval(timer); timer = null; }
|
|
1300
|
-
};
|
|
1301
|
-
|
|
1302
|
-
return emitter;
|
|
1107
|
+
return _monitorFeeGrants(opts);
|
|
1303
1108
|
}
|
|
1304
1109
|
|
|
1305
1110
|
// ─── Query Helpers (v25c) ────────────────────────────────────────────────────
|
|
@@ -1311,10 +1116,7 @@ export function monitorFeeGrants(opts = {}) {
|
|
|
1311
1116
|
* @returns {Promise<{ subscriptions: any[], total: number|null }>}
|
|
1312
1117
|
*/
|
|
1313
1118
|
export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
|
|
1314
|
-
|
|
1315
|
-
let path = `/sentinel/subscription/v3/accounts/${walletAddr}/subscriptions`;
|
|
1316
|
-
if (opts.status) path += `?status=${opts.status === 'active' ? '1' : '2'}`;
|
|
1317
|
-
return lcdQueryAll(path, { lcdUrl, dataKey: 'subscriptions' });
|
|
1119
|
+
return _querySubscriptions(lcdUrl, walletAddr, opts);
|
|
1318
1120
|
}
|
|
1319
1121
|
|
|
1320
1122
|
/**
|
|
@@ -1324,20 +1126,7 @@ export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
|
|
|
1324
1126
|
* @returns {Promise<{ maxBytes: number, usedBytes: number, remainingBytes: number, percentUsed: number }|null>}
|
|
1325
1127
|
*/
|
|
1326
1128
|
export async function querySessionAllocation(lcdUrl, sessionId) {
|
|
1327
|
-
|
|
1328
|
-
const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
|
|
1329
|
-
const s = data.session?.base_session || data.session || {};
|
|
1330
|
-
const maxBytes = parseInt(s.max_bytes || '0', 10);
|
|
1331
|
-
const dl = parseInt(s.download_bytes || '0', 10);
|
|
1332
|
-
const ul = parseInt(s.upload_bytes || '0', 10);
|
|
1333
|
-
const usedBytes = dl + ul;
|
|
1334
|
-
return {
|
|
1335
|
-
maxBytes,
|
|
1336
|
-
usedBytes,
|
|
1337
|
-
remainingBytes: Math.max(0, maxBytes - usedBytes),
|
|
1338
|
-
percentUsed: maxBytes > 0 ? Math.round((usedBytes / maxBytes) * 100) : 0,
|
|
1339
|
-
};
|
|
1340
|
-
} catch { return null; }
|
|
1129
|
+
return _querySessionAllocation(lcdUrl, sessionId);
|
|
1341
1130
|
}
|
|
1342
1131
|
|
|
1343
1132
|
/**
|
|
@@ -1350,28 +1139,7 @@ export async function querySessionAllocation(lcdUrl, sessionId) {
|
|
|
1350
1139
|
* @returns {Promise<object>} Node object with remote_url resolved
|
|
1351
1140
|
*/
|
|
1352
1141
|
export async function queryNode(nodeAddress, opts = {}) {
|
|
1353
|
-
|
|
1354
|
-
throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be sentnode1...', { nodeAddress });
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
const fetchDirect = async (baseUrl) => {
|
|
1358
|
-
try {
|
|
1359
|
-
const data = await lcdQuery(`/sentinel/node/v3/nodes/${nodeAddress}`, { lcdUrl: baseUrl });
|
|
1360
|
-
if (data?.node) {
|
|
1361
|
-
data.node.remote_url = resolveNodeUrl(data.node);
|
|
1362
|
-
return data.node;
|
|
1363
|
-
}
|
|
1364
|
-
} catch { /* fall through to full list */ }
|
|
1365
|
-
const { items } = await lcdPaginatedSafe(baseUrl, '/sentinel/node/v3/nodes?status=1', 'nodes');
|
|
1366
|
-
const found = items.find(n => n.address === nodeAddress);
|
|
1367
|
-
if (!found) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found on LCD (may be inactive)`, { nodeAddress });
|
|
1368
|
-
found.remote_url = resolveNodeUrl(found);
|
|
1369
|
-
return found;
|
|
1370
|
-
};
|
|
1371
|
-
|
|
1372
|
-
if (opts.lcdUrl) return fetchDirect(opts.lcdUrl);
|
|
1373
|
-
const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `LCD node lookup ${nodeAddress}`);
|
|
1374
|
-
return result;
|
|
1142
|
+
return _queryNode(nodeAddress, opts);
|
|
1375
1143
|
}
|
|
1376
1144
|
|
|
1377
1145
|
/**
|
|
@@ -1467,12 +1235,7 @@ export async function lcdPaginatedSafe(lcdUrl, path, itemsKey, opts = {}) {
|
|
|
1467
1235
|
* @returns {Promise<{ items: ChainSession[], total: number }>}
|
|
1468
1236
|
*/
|
|
1469
1237
|
export async function querySessions(address, lcdUrl, opts = {}) {
|
|
1470
|
-
|
|
1471
|
-
if (opts.status) path += `&status=${opts.status}`;
|
|
1472
|
-
const result = await lcdPaginatedSafe(lcdUrl, path, 'sessions');
|
|
1473
|
-
// Auto-flatten base_session nesting so devs don't hit session.id === undefined
|
|
1474
|
-
result.items = result.items.map(flattenSession);
|
|
1475
|
-
return result;
|
|
1238
|
+
return _querySessions(address, lcdUrl, opts);
|
|
1476
1239
|
}
|
|
1477
1240
|
|
|
1478
1241
|
/**
|
|
@@ -1484,27 +1247,7 @@ export async function querySessions(address, lcdUrl, opts = {}) {
|
|
|
1484
1247
|
* @returns {object} Flattened session with all fields at top level
|
|
1485
1248
|
*/
|
|
1486
1249
|
export function flattenSession(session) {
|
|
1487
|
-
|
|
1488
|
-
const bs = session.base_session || {};
|
|
1489
|
-
return {
|
|
1490
|
-
id: bs.id || session.id,
|
|
1491
|
-
acc_address: bs.acc_address || session.acc_address,
|
|
1492
|
-
node_address: bs.node_address || bs.node || session.node_address,
|
|
1493
|
-
download_bytes: bs.download_bytes || session.download_bytes || '0',
|
|
1494
|
-
upload_bytes: bs.upload_bytes || session.upload_bytes || '0',
|
|
1495
|
-
max_bytes: bs.max_bytes || session.max_bytes || '0',
|
|
1496
|
-
duration: bs.duration || session.duration,
|
|
1497
|
-
max_duration: bs.max_duration || session.max_duration,
|
|
1498
|
-
status: bs.status || session.status,
|
|
1499
|
-
start_at: bs.start_at || session.start_at,
|
|
1500
|
-
status_at: bs.status_at || session.status_at,
|
|
1501
|
-
inactive_at: bs.inactive_at || session.inactive_at,
|
|
1502
|
-
// Preserve type-specific fields
|
|
1503
|
-
price: session.price || undefined,
|
|
1504
|
-
subscription_id: session.subscription_id || undefined,
|
|
1505
|
-
'@type': session['@type'] || undefined,
|
|
1506
|
-
_raw: session, // original for advanced use
|
|
1507
|
-
};
|
|
1250
|
+
return _flattenSession(session);
|
|
1508
1251
|
}
|
|
1509
1252
|
|
|
1510
1253
|
/**
|
|
@@ -1514,10 +1257,7 @@ export function flattenSession(session) {
|
|
|
1514
1257
|
* @returns {Promise<Subscription|null>}
|
|
1515
1258
|
*/
|
|
1516
1259
|
export async function querySubscription(id, lcdUrl) {
|
|
1517
|
-
|
|
1518
|
-
const data = await lcdQuery(`/sentinel/subscription/v3/subscriptions/${id}`, { lcdUrl });
|
|
1519
|
-
return data.subscription || null;
|
|
1520
|
-
} catch { return null; }
|
|
1260
|
+
return _querySubscription(id, lcdUrl);
|
|
1521
1261
|
}
|
|
1522
1262
|
|
|
1523
1263
|
/**
|
|
@@ -1528,10 +1268,7 @@ export async function querySubscription(id, lcdUrl) {
|
|
|
1528
1268
|
* @returns {Promise<{ has: boolean, subscription?: object }>}
|
|
1529
1269
|
*/
|
|
1530
1270
|
export async function hasActiveSubscription(address, planId, lcdUrl) {
|
|
1531
|
-
|
|
1532
|
-
const match = items.find(s => String(s.plan_id) === String(planId));
|
|
1533
|
-
if (match) return { has: true, subscription: match };
|
|
1534
|
-
return { has: false };
|
|
1271
|
+
return _hasActiveSubscription(address, planId, lcdUrl);
|
|
1535
1272
|
}
|
|
1536
1273
|
|
|
1537
1274
|
// ─── v26c: Display Helpers ───────────────────────────────────────────────────
|
|
@@ -1572,15 +1309,7 @@ export function parseChainDuration(durationStr) {
|
|
|
1572
1309
|
* @returns {Promise<{ items: any[], total: number|null }>}
|
|
1573
1310
|
*/
|
|
1574
1311
|
export async function queryPlanNodes(planId, lcdUrl) {
|
|
1575
|
-
|
|
1576
|
-
// and next_key is always null. Single high-limit request gets all nodes.
|
|
1577
|
-
const doQuery = async (baseUrl) => {
|
|
1578
|
-
const data = await lcd(baseUrl, `/sentinel/node/v3/plans/${planId}/nodes?pagination.limit=5000`);
|
|
1579
|
-
return { items: data.nodes || [], total: (data.nodes || []).length };
|
|
1580
|
-
};
|
|
1581
|
-
if (lcdUrl) return doQuery(lcdUrl);
|
|
1582
|
-
const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD plan ${planId} nodes`);
|
|
1583
|
-
return result;
|
|
1312
|
+
return _queryPlanNodes(planId, lcdUrl);
|
|
1584
1313
|
}
|
|
1585
1314
|
|
|
1586
1315
|
/**
|
|
@@ -1595,33 +1324,7 @@ export async function queryPlanNodes(planId, lcdUrl) {
|
|
|
1595
1324
|
* @returns {Promise<Array<{ id: number, subscribers: number, nodeCount: number, price: object|null, hasNodes: boolean }>>}
|
|
1596
1325
|
*/
|
|
1597
1326
|
export async function discoverPlans(lcdUrl, opts = {}) {
|
|
1598
|
-
|
|
1599
|
-
const batchSize = opts.batchSize || 15;
|
|
1600
|
-
const includeEmpty = opts.includeEmpty || false;
|
|
1601
|
-
const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
|
|
1602
|
-
const plans = [];
|
|
1603
|
-
|
|
1604
|
-
for (let batch = 0; batch < Math.ceil(maxId / batchSize); batch++) {
|
|
1605
|
-
const probes = [];
|
|
1606
|
-
for (let i = batch * batchSize + 1; i <= Math.min((batch + 1) * batchSize, maxId); i++) {
|
|
1607
|
-
probes.push((async (id) => {
|
|
1608
|
-
try {
|
|
1609
|
-
const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
|
|
1610
|
-
const subCount = parseInt(subData.pagination?.total || '0', 10);
|
|
1611
|
-
if (subCount === 0 && !includeEmpty) return null;
|
|
1612
|
-
// Plan nodes endpoint has broken pagination (count_total wrong, next_key null).
|
|
1613
|
-
// Use limit=5000 single request and count the actual array.
|
|
1614
|
-
const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
|
|
1615
|
-
const nodeCount = (nodeData.nodes || []).length;
|
|
1616
|
-
const price = subData.subscriptions?.[0]?.price || null;
|
|
1617
|
-
return { id, subscribers: subCount, nodeCount, price, hasNodes: nodeCount > 0 };
|
|
1618
|
-
} catch { return null; }
|
|
1619
|
-
})(i));
|
|
1620
|
-
}
|
|
1621
|
-
const results = await Promise.all(probes);
|
|
1622
|
-
for (const r of results) if (r) plans.push(r);
|
|
1623
|
-
}
|
|
1624
|
-
return plans.sort((a, b) => a.id - b.id);
|
|
1327
|
+
return _discoverPlans(lcdUrl, opts);
|
|
1625
1328
|
}
|
|
1626
1329
|
|
|
1627
1330
|
/**
|
|
@@ -1709,10 +1412,7 @@ export async function subscribeToPlan(client, fromAddress, planId, denom = 'udvp
|
|
|
1709
1412
|
* @returns {Promise<object|null>}
|
|
1710
1413
|
*/
|
|
1711
1414
|
export async function getProviderByAddress(provAddress, opts = {}) {
|
|
1712
|
-
|
|
1713
|
-
const data = await lcdQuery(`/sentinel/provider/v2/providers/${provAddress}`, opts);
|
|
1714
|
-
return data.provider || null;
|
|
1715
|
-
} catch { return null; }
|
|
1415
|
+
return _getProviderByAddress(provAddress, opts);
|
|
1716
1416
|
}
|
|
1717
1417
|
|
|
1718
1418
|
/**
|
|
@@ -1904,7 +1604,6 @@ export const MSG_TYPES = {
|
|
|
1904
1604
|
// v27: Persistent user settings (backported from C# VpnSettings.cs).
|
|
1905
1605
|
// Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.
|
|
1906
1606
|
|
|
1907
|
-
const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
|
|
1908
1607
|
|
|
1909
1608
|
/**
|
|
1910
1609
|
* Load persisted VPN settings from disk.
|
|
@@ -1912,10 +1611,7 @@ const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
|
|
|
1912
1611
|
* @returns {Record<string, any>}
|
|
1913
1612
|
*/
|
|
1914
1613
|
export function loadVpnSettings() {
|
|
1915
|
-
|
|
1916
|
-
if (!existsSync(SETTINGS_FILE)) return {};
|
|
1917
|
-
return JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8'));
|
|
1918
|
-
} catch { return {}; }
|
|
1614
|
+
return _loadVpnSettings();
|
|
1919
1615
|
}
|
|
1920
1616
|
|
|
1921
1617
|
/**
|
|
@@ -1923,7 +1619,5 @@ export function loadVpnSettings() {
|
|
|
1923
1619
|
* @param {Record<string, any>} settings
|
|
1924
1620
|
*/
|
|
1925
1621
|
export function saveVpnSettings(settings) {
|
|
1926
|
-
|
|
1927
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1928
|
-
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
1622
|
+
return _saveVpnSettings(settings);
|
|
1929
1623
|
}
|
package/defaults.js
CHANGED
|
@@ -25,7 +25,7 @@ axios.defaults.adapter = 'http';
|
|
|
25
25
|
// This is the npm/semver version for consumers. Internal development iterations
|
|
26
26
|
// (v20, v21, v22, etc.) track feature milestones and are not exposed as exports.
|
|
27
27
|
|
|
28
|
-
export const SDK_VERSION = '
|
|
28
|
+
export const SDK_VERSION = '2.3.0';
|
|
29
29
|
|
|
30
30
|
// ─── Timestamps ──────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
@@ -364,3 +364,123 @@ export async function tryWithFallback(endpoints, operation, label = 'operation')
|
|
|
364
364
|
const { ChainError } = await import('./errors.js');
|
|
365
365
|
throw new ChainError('ALL_ENDPOINTS_FAILED', `${label} failed on all ${endpoints.length} endpoints (verified ${LAST_VERIFIED}):\n${tried}\n\nAll endpoints may be down, or your network may be blocking HTTPS. Try curl-ing the URLs manually.`, { endpoints: errors });
|
|
366
366
|
}
|
|
367
|
+
|
|
368
|
+
// ─── Runtime Endpoint Management ────────────────────────────────────────────
|
|
369
|
+
// Add/remove/reorder RPC and LCD endpoints at runtime without code changes.
|
|
370
|
+
// The arrays above are `const` but are Objects (mutable contents).
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Add an RPC endpoint at runtime. Skips if URL already exists.
|
|
374
|
+
* @param {string} url - RPC URL (e.g. 'https://rpc.newprovider.com')
|
|
375
|
+
* @param {string} [name='Custom'] - Provider name
|
|
376
|
+
* @param {boolean} [prepend=false] - If true, adds to front (highest priority)
|
|
377
|
+
*/
|
|
378
|
+
export function addRpcEndpoint(url, name = 'Custom', prepend = false) {
|
|
379
|
+
if (RPC_ENDPOINTS.some(e => e.url === url)) return;
|
|
380
|
+
const entry = { url, name, verified: new Date().toISOString().slice(0, 10) };
|
|
381
|
+
prepend ? RPC_ENDPOINTS.unshift(entry) : RPC_ENDPOINTS.push(entry);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Add an LCD endpoint at runtime. Skips if URL already exists.
|
|
386
|
+
* @param {string} url - LCD URL (e.g. 'https://api.newprovider.com')
|
|
387
|
+
* @param {string} [name='Custom'] - Provider name
|
|
388
|
+
* @param {boolean} [prepend=false] - If true, adds to front (highest priority)
|
|
389
|
+
*/
|
|
390
|
+
export function addLcdEndpoint(url, name = 'Custom', prepend = false) {
|
|
391
|
+
if (LCD_ENDPOINTS.some(e => e.url === url)) return;
|
|
392
|
+
const entry = { url, name, verified: new Date().toISOString().slice(0, 10) };
|
|
393
|
+
prepend ? LCD_ENDPOINTS.unshift(entry) : LCD_ENDPOINTS.push(entry);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Remove an RPC endpoint by URL.
|
|
398
|
+
* @param {string} url
|
|
399
|
+
*/
|
|
400
|
+
export function removeRpcEndpoint(url) {
|
|
401
|
+
const idx = RPC_ENDPOINTS.findIndex(e => e.url === url);
|
|
402
|
+
if (idx !== -1) RPC_ENDPOINTS.splice(idx, 1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Remove an LCD endpoint by URL.
|
|
407
|
+
* @param {string} url
|
|
408
|
+
*/
|
|
409
|
+
export function removeLcdEndpoint(url) {
|
|
410
|
+
const idx = LCD_ENDPOINTS.findIndex(e => e.url === url);
|
|
411
|
+
if (idx !== -1) LCD_ENDPOINTS.splice(idx, 1);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Replace ALL endpoints at once (e.g. from a config file or remote registry).
|
|
416
|
+
* @param {'rpc'|'lcd'} type
|
|
417
|
+
* @param {Array<{url: string, name?: string}>} endpoints
|
|
418
|
+
*/
|
|
419
|
+
export function setEndpoints(type, endpoints) {
|
|
420
|
+
const target = type === 'rpc' ? RPC_ENDPOINTS : LCD_ENDPOINTS;
|
|
421
|
+
target.length = 0;
|
|
422
|
+
for (const ep of endpoints) {
|
|
423
|
+
target.push({ url: ep.url, name: ep.name || 'Custom', verified: new Date().toISOString().slice(0, 10) });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Get current endpoint lists (for inspection/debugging).
|
|
429
|
+
* @returns {{ rpc: Array, lcd: Array }}
|
|
430
|
+
*/
|
|
431
|
+
export function getEndpoints() {
|
|
432
|
+
return { rpc: [...RPC_ENDPOINTS], lcd: [...LCD_ENDPOINTS] };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Health-check RPC endpoints and reorder by latency (fastest first).
|
|
437
|
+
* Uses Tendermint /status endpoint which returns node info + sync status.
|
|
438
|
+
* @param {number} [timeoutMs=5000] - Timeout per endpoint
|
|
439
|
+
* @returns {Promise<Array<{url: string, name: string, latencyMs: number|null, blockHeight?: number}>>}
|
|
440
|
+
*/
|
|
441
|
+
export async function checkRpcEndpointHealth(timeoutMs = 5000) {
|
|
442
|
+
const results = await Promise.all(RPC_ENDPOINTS.map(async (ep) => {
|
|
443
|
+
const start = Date.now();
|
|
444
|
+
try {
|
|
445
|
+
const controller = new AbortController();
|
|
446
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
447
|
+
const resp = await axios.get(`${ep.url}/status`, { signal: controller.signal, timeout: timeoutMs });
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
const height = parseInt(resp.data?.result?.sync_info?.latest_block_height || '0', 10);
|
|
450
|
+
return { ...ep, latencyMs: Date.now() - start, blockHeight: height };
|
|
451
|
+
} catch {
|
|
452
|
+
return { ...ep, latencyMs: null };
|
|
453
|
+
}
|
|
454
|
+
}));
|
|
455
|
+
return results.sort((a, b) => {
|
|
456
|
+
if (a.latencyMs != null && b.latencyMs != null) return a.latencyMs - b.latencyMs;
|
|
457
|
+
if (a.latencyMs != null) return -1;
|
|
458
|
+
if (b.latencyMs != null) return 1;
|
|
459
|
+
return 0;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Health-check both RPC and LCD endpoints, reorder by latency.
|
|
465
|
+
* Moves fastest-responding endpoints to the front of each array.
|
|
466
|
+
* @param {number} [timeoutMs=5000]
|
|
467
|
+
* @returns {Promise<{ rpc: Array, lcd: Array }>}
|
|
468
|
+
*/
|
|
469
|
+
export async function optimizeEndpoints(timeoutMs = 5000) {
|
|
470
|
+
const [rpcResults, lcdResults] = await Promise.all([
|
|
471
|
+
checkRpcEndpointHealth(timeoutMs),
|
|
472
|
+
checkEndpointHealth(LCD_ENDPOINTS, timeoutMs),
|
|
473
|
+
]);
|
|
474
|
+
// Reorder arrays in-place: healthy first, by latency
|
|
475
|
+
const reorder = (target, results) => {
|
|
476
|
+
const healthy = results.filter(r => r.latencyMs != null);
|
|
477
|
+
const dead = results.filter(r => r.latencyMs == null);
|
|
478
|
+
target.length = 0;
|
|
479
|
+
for (const r of [...healthy, ...dead]) {
|
|
480
|
+
target.push({ url: r.url, name: r.name, verified: r.verified || LAST_VERIFIED });
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
reorder(RPC_ENDPOINTS, rpcResults);
|
|
484
|
+
reorder(LCD_ENDPOINTS, lcdResults);
|
|
485
|
+
return { rpc: rpcResults, lcd: lcdResults };
|
|
486
|
+
}
|
package/index.js
CHANGED
|
@@ -307,6 +307,10 @@ export {
|
|
|
307
307
|
rpcQueryPlan,
|
|
308
308
|
rpcQueryBalance,
|
|
309
309
|
rpcQueryFeeGrant,
|
|
310
|
+
rpcQueryFeeGrants,
|
|
311
|
+
rpcQueryFeeGrantsIssued,
|
|
312
|
+
rpcQueryAuthzGrants,
|
|
313
|
+
rpcQueryProvider,
|
|
310
314
|
} from './chain/rpc.js';
|
|
311
315
|
|
|
312
316
|
// ─── Subscription Sharing (plan operator → user onboarding) ────────────────
|
|
@@ -384,6 +388,15 @@ export {
|
|
|
384
388
|
DEFAULT_DNS_PRESET,
|
|
385
389
|
DNS_FALLBACK_ORDER,
|
|
386
390
|
resolveDnsServers,
|
|
391
|
+
// Runtime endpoint management
|
|
392
|
+
addRpcEndpoint,
|
|
393
|
+
addLcdEndpoint,
|
|
394
|
+
removeRpcEndpoint,
|
|
395
|
+
removeLcdEndpoint,
|
|
396
|
+
setEndpoints,
|
|
397
|
+
getEndpoints,
|
|
398
|
+
checkRpcEndpointHealth,
|
|
399
|
+
optimizeEndpoints,
|
|
387
400
|
} from './defaults.js';
|
|
388
401
|
|
|
389
402
|
// ─── Typed Errors ────────────────────────────────────────────────────────────
|
package/node-connect.js
CHANGED
|
@@ -33,10 +33,14 @@ import os from 'os';
|
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
35
|
createWallet, privKeyFromMnemonic, createClient, broadcast, broadcastWithFeeGrant,
|
|
36
|
-
extractId,
|
|
37
|
-
|
|
36
|
+
extractId, getBalance, MSG_TYPES,
|
|
37
|
+
filterNodes, buildEndSessionMsg,
|
|
38
38
|
} from './cosmjs-setup.js';
|
|
39
39
|
|
|
40
|
+
import {
|
|
41
|
+
findExistingSession, fetchActiveNodes, queryNode, resolveNodeUrl,
|
|
42
|
+
} from './chain/queries.js';
|
|
43
|
+
|
|
40
44
|
import {
|
|
41
45
|
nodeStatusV3, generateWgKeyPair, initHandshakeV3,
|
|
42
46
|
writeWgConfig, generateV2RayUUID, initHandshakeV3V2Ray,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blue-js-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Decentralized VPN SDK for the Sentinel P2P bandwidth network. WireGuard + V2Ray tunnels, Cosmos blockchain, 900+ nodes. Tested on Windows. macOS/Linux support included but untested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/session-manager.js
CHANGED
|
@@ -21,7 +21,7 @@ import path from 'path';
|
|
|
21
21
|
import os from 'os';
|
|
22
22
|
import { ChainError, ErrorCodes } from './errors.js';
|
|
23
23
|
import { DEFAULT_LCD } from './defaults.js';
|
|
24
|
-
import {
|
|
24
|
+
import { querySessions } from './chain/queries.js';
|
|
25
25
|
import { loadPoisonedKeys, savePoisonedKeys } from './state.js';
|
|
26
26
|
|
|
27
27
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -94,11 +94,11 @@ export class SessionManager {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const map = new Map();
|
|
97
|
-
const queryPath = `/sentinel/session/v3/sessions?address=${addr}&status=1`;
|
|
98
97
|
|
|
99
98
|
let items;
|
|
100
99
|
try {
|
|
101
|
-
|
|
100
|
+
// RPC-first via chain/queries.js — returns flattened sessions
|
|
101
|
+
const result = await querySessions(addr, this._lcdUrl, { status: '1' });
|
|
102
102
|
items = result.items || [];
|
|
103
103
|
} catch (err) {
|
|
104
104
|
throw new ChainError(
|
|
@@ -109,13 +109,15 @@ export class SessionManager {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
for (const s of items) {
|
|
112
|
+
// querySessions returns flat sessions (base_session unwrapped)
|
|
112
113
|
const bs = s.base_session || s;
|
|
113
114
|
const nodeAddr = bs.node_address || bs.node;
|
|
114
115
|
if (!nodeAddr) continue;
|
|
115
116
|
|
|
116
117
|
const acct = bs.acc_address || bs.address;
|
|
117
118
|
if (acct && acct !== addr) continue;
|
|
118
|
-
|
|
119
|
+
// RPC returns status as number (1=active), LCD as string
|
|
120
|
+
if (bs.status && bs.status !== 'active' && bs.status !== 1) continue;
|
|
119
121
|
|
|
120
122
|
const maxBytes = parseInt(bs.max_bytes || '0');
|
|
121
123
|
const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
|