blue-js-sdk 2.0.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 +446 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/ai-path/ADMIN-ELEVATION.md +116 -0
- package/ai-path/AI-MANIFESTO.md +185 -0
- package/ai-path/BREAKING.md +74 -0
- package/ai-path/CHECKLIST.md +619 -0
- package/ai-path/CONNECTION-STEPS.md +724 -0
- package/ai-path/DECISION-TREE.md +378 -0
- package/ai-path/DEPENDENCIES.md +459 -0
- package/ai-path/E2E-FLOW.md +1555 -0
- package/ai-path/FAILURES.md +403 -0
- package/ai-path/GUIDE.md +1217 -0
- package/ai-path/README.md +558 -0
- package/ai-path/SPLIT-TUNNEL.md +266 -0
- package/ai-path/cli.js +535 -0
- package/ai-path/connect.js +884 -0
- package/ai-path/discover.js +178 -0
- package/ai-path/environment.js +266 -0
- package/ai-path/errors.js +86 -0
- package/ai-path/examples/autonomous-agent.mjs +220 -0
- package/ai-path/examples/multi-region.mjs +174 -0
- package/ai-path/examples/one-shot.mjs +31 -0
- package/ai-path/index.js +60 -0
- package/ai-path/pricing.js +136 -0
- package/ai-path/recommend.js +413 -0
- package/ai-path/run-admin.vbs +25 -0
- package/ai-path/setup.js +291 -0
- package/ai-path/wallet.js +137 -0
- package/app-helpers.js +363 -0
- package/app-settings.js +95 -0
- package/app-types.js +267 -0
- package/audit.js +847 -0
- package/batch.js +293 -0
- package/bin/setup.js +376 -0
- package/chain/authz.js +109 -0
- package/chain/broadcast.js +472 -0
- package/chain/client.js +160 -0
- package/chain/fee-grants.js +305 -0
- package/chain/index.js +891 -0
- package/chain/lcd.js +313 -0
- package/chain/queries.js +547 -0
- package/chain/rpc.js +408 -0
- package/chain/wallet.js +141 -0
- package/cli/config.js +143 -0
- package/cli/index.js +463 -0
- package/cli/output.js +182 -0
- package/cli.js +491 -0
- package/client/index.js +251 -0
- package/client.js +271 -0
- package/config/index.js +255 -0
- package/connection/connect.js +849 -0
- package/connection/disconnect.js +180 -0
- package/connection/discovery.js +321 -0
- package/connection/index.js +76 -0
- package/connection/proxy.js +148 -0
- package/connection/resilience.js +428 -0
- package/connection/security.js +232 -0
- package/connection/state.js +369 -0
- package/connection/tunnel.js +691 -0
- package/consumer.js +132 -0
- package/cosmjs-setup.js +1884 -0
- package/defaults.js +366 -0
- package/disk-cache.js +107 -0
- package/dist/client.d.ts +108 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +400 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/errors/index.js +112 -0
- package/errors.js +218 -0
- package/examples/README.md +64 -0
- package/examples/connect-direct.mjs +106 -0
- package/examples/connect-plan.mjs +125 -0
- package/examples/error-handling.mjs +109 -0
- package/examples/query-nodes.mjs +94 -0
- package/examples/wallet-basics.mjs +61 -0
- package/generated/amino/amino.ts +9 -0
- package/generated/cosmos/base/v1beta1/coin.ts +365 -0
- package/generated/cosmos_proto/cosmos.ts +323 -0
- package/generated/gogoproto/gogo.ts +9 -0
- package/generated/google/protobuf/descriptor.ts +7601 -0
- package/generated/google/protobuf/duration.ts +208 -0
- package/generated/google/protobuf/timestamp.ts +238 -0
- package/generated/sentinel/lease/v1/events.ts +924 -0
- package/generated/sentinel/lease/v1/lease.ts +292 -0
- package/generated/sentinel/lease/v1/msg.ts +949 -0
- package/generated/sentinel/lease/v1/params.ts +164 -0
- package/generated/sentinel/node/v3/events.ts +881 -0
- package/generated/sentinel/node/v3/msg.ts +1002 -0
- package/generated/sentinel/node/v3/node.ts +263 -0
- package/generated/sentinel/node/v3/params.ts +183 -0
- package/generated/sentinel/plan/v3/events.ts +675 -0
- package/generated/sentinel/plan/v3/msg.ts +1191 -0
- package/generated/sentinel/plan/v3/plan.ts +283 -0
- package/generated/sentinel/provider/v2/events.ts +171 -0
- package/generated/sentinel/provider/v2/msg.ts +480 -0
- package/generated/sentinel/provider/v2/params.ts +131 -0
- package/generated/sentinel/provider/v2/provider.ts +246 -0
- package/generated/sentinel/session/v3/events.ts +480 -0
- package/generated/sentinel/session/v3/msg.ts +616 -0
- package/generated/sentinel/session/v3/params.ts +260 -0
- package/generated/sentinel/session/v3/proof.ts +180 -0
- package/generated/sentinel/session/v3/session.ts +384 -0
- package/generated/sentinel/subscription/v3/events.ts +1181 -0
- package/generated/sentinel/subscription/v3/msg.ts +1305 -0
- package/generated/sentinel/subscription/v3/params.ts +167 -0
- package/generated/sentinel/subscription/v3/subscription.ts +315 -0
- package/generated/sentinel/types/v1/bandwidth.ts +124 -0
- package/generated/sentinel/types/v1/price.ts +149 -0
- package/generated/sentinel/types/v1/renewal.ts +87 -0
- package/generated/sentinel/types/v1/status.ts +54 -0
- package/generated/typeRegistry.ts +27 -0
- package/index.js +486 -0
- package/node-connect.js +3015 -0
- package/operator.js +134 -0
- package/package.json +113 -0
- package/plan-operations.js +199 -0
- package/preflight.js +352 -0
- package/pricing/index.js +262 -0
- package/proto/amino/amino.proto +84 -0
- package/proto/cosmos/base/v1beta1/coin.proto +61 -0
- package/proto/cosmos_proto/cosmos.proto +112 -0
- package/proto/gogoproto/gogo.proto +145 -0
- package/proto/google/api/annotations.proto +31 -0
- package/proto/google/api/http.proto +370 -0
- package/proto/google/protobuf/any.proto +106 -0
- package/proto/google/protobuf/duration.proto +115 -0
- package/proto/google/protobuf/timestamp.proto +145 -0
- package/proto/sentinel/lease/v1/events.proto +52 -0
- package/proto/sentinel/lease/v1/genesis.proto +15 -0
- package/proto/sentinel/lease/v1/lease.proto +25 -0
- package/proto/sentinel/lease/v1/msg.proto +62 -0
- package/proto/sentinel/lease/v1/params.proto +17 -0
- package/proto/sentinel/node/v3/events.proto +50 -0
- package/proto/sentinel/node/v3/genesis.proto +15 -0
- package/proto/sentinel/node/v3/msg.proto +63 -0
- package/proto/sentinel/node/v3/node.proto +27 -0
- package/proto/sentinel/node/v3/params.proto +21 -0
- package/proto/sentinel/node/v3/querier.proto +63 -0
- package/proto/sentinel/plan/v3/events.proto +41 -0
- package/proto/sentinel/plan/v3/genesis.proto +21 -0
- package/proto/sentinel/plan/v3/msg.proto +83 -0
- package/proto/sentinel/plan/v3/plan.proto +32 -0
- package/proto/sentinel/plan/v3/querier.proto +53 -0
- package/proto/sentinel/provider/v2/events.proto +16 -0
- package/proto/sentinel/provider/v2/genesis.proto +15 -0
- package/proto/sentinel/provider/v2/msg.proto +35 -0
- package/proto/sentinel/provider/v2/params.proto +17 -0
- package/proto/sentinel/provider/v2/provider.proto +24 -0
- package/proto/sentinel/provider/v3/genesis.proto +15 -0
- package/proto/sentinel/provider/v3/params.proto +13 -0
- package/proto/sentinel/session/v3/events.proto +30 -0
- package/proto/sentinel/session/v3/genesis.proto +15 -0
- package/proto/sentinel/session/v3/msg.proto +50 -0
- package/proto/sentinel/session/v3/params.proto +25 -0
- package/proto/sentinel/session/v3/proof.proto +25 -0
- package/proto/sentinel/session/v3/querier.proto +100 -0
- package/proto/sentinel/session/v3/session.proto +50 -0
- package/proto/sentinel/subscription/v2/allocation.proto +21 -0
- package/proto/sentinel/subscription/v2/payout.proto +22 -0
- package/proto/sentinel/subscription/v3/events.proto +65 -0
- package/proto/sentinel/subscription/v3/genesis.proto +17 -0
- package/proto/sentinel/subscription/v3/msg.proto +83 -0
- package/proto/sentinel/subscription/v3/params.proto +21 -0
- package/proto/sentinel/subscription/v3/subscription.proto +33 -0
- package/proto/sentinel/types/v1/bandwidth.proto +19 -0
- package/proto/sentinel/types/v1/price.proto +21 -0
- package/proto/sentinel/types/v1/renewal.proto +21 -0
- package/proto/sentinel/types/v1/status.proto +16 -0
- package/protocol/encoding.js +341 -0
- package/protocol/events.js +361 -0
- package/protocol/handshake.js +297 -0
- package/protocol/index.js +15 -0
- package/protocol/messages.js +346 -0
- package/protocol/plans.js +199 -0
- package/protocol/v2ray.js +268 -0
- package/protocol/v3.js +723 -0
- package/protocol/wireguard.js +125 -0
- package/security/index.js +132 -0
- package/session-manager.js +329 -0
- package/session-tracker.js +80 -0
- package/setup.js +376 -0
- package/speedtest/index.js +528 -0
- package/speedtest.js +567 -0
- package/src/client.ts +502 -0
- package/src/index.ts +20 -0
- package/state/index.js +347 -0
- package/state.js +516 -0
- package/test-all-chain-ops.js +493 -0
- package/test-all-logic.js +199 -0
- package/test-all-msg-types.js +292 -0
- package/test-every-connection.js +208 -0
- package/test-feegrant-connect.js +98 -0
- package/test-logic.js +148 -0
- package/test-mainnet.js +176 -0
- package/test-plan-lifecycle.js +335 -0
- package/tls-trust.js +132 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +34 -0
- package/types/chain.d.ts +746 -0
- package/types/connection.d.ts +425 -0
- package/types/errors.d.ts +174 -0
- package/types/index.d.ts +1380 -0
- package/types/nodes.d.ts +187 -0
- package/types/pricing.d.ts +156 -0
- package/types/protocol.d.ts +332 -0
- package/types/session.d.ts +236 -0
- package/types/settings.d.ts +192 -0
- package/v3protocol.js +1053 -0
- package/wallet/index.js +153 -0
- package/wireguard.js +307 -0
package/chain/queries.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Chain / Queries Module
|
|
3
|
+
*
|
|
4
|
+
* All LCD-based query functions: balance, nodes, sessions, subscriptions,
|
|
5
|
+
* plans, pricing, discovery, and display/serialization helpers.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { getBalance, fetchActiveNodes, queryNode } from './chain/queries.js';
|
|
9
|
+
* const nodes = await fetchActiveNodes(lcdUrl);
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
15
|
+
import { LCD_ENDPOINTS, tryWithFallback } from '../defaults.js';
|
|
16
|
+
import { ValidationError, NodeError, ChainError, ErrorCodes } from '../errors.js';
|
|
17
|
+
import { extractSessionId } from '../v3protocol.js';
|
|
18
|
+
import { lcd, lcdQuery, lcdQueryAll, lcdPaginatedSafe } from './lcd.js';
|
|
19
|
+
|
|
20
|
+
// Re-export for convenience
|
|
21
|
+
export { extractSessionId };
|
|
22
|
+
|
|
23
|
+
// ─── Query Helpers ───────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check wallet balance.
|
|
27
|
+
* Returns { udvpn: number, dvpn: number }
|
|
28
|
+
*/
|
|
29
|
+
export async function getBalance(client, address) {
|
|
30
|
+
const bal = await client.getBalance(address, 'udvpn');
|
|
31
|
+
const amount = parseInt(bal?.amount || '0', 10) || 0;
|
|
32
|
+
return { udvpn: amount, dvpn: amount / 1_000_000 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find an existing active session for a wallet+node pair.
|
|
37
|
+
* Returns session ID (BigInt) or null. Use this to avoid double-paying.
|
|
38
|
+
*
|
|
39
|
+
* Note: Sessions have a nested base_session object containing the actual data.
|
|
40
|
+
*/
|
|
41
|
+
export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
|
|
42
|
+
const { items } = await lcdPaginatedSafe(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`, 'sessions');
|
|
43
|
+
for (const s of items) {
|
|
44
|
+
const bs = s.base_session || s;
|
|
45
|
+
if ((bs.node_address || bs.node) !== nodeAddr) continue;
|
|
46
|
+
if (bs.status && bs.status !== 'active') continue;
|
|
47
|
+
const acct = bs.acc_address || bs.address;
|
|
48
|
+
if (acct && acct !== walletAddr) continue;
|
|
49
|
+
const maxBytes = parseInt(bs.max_bytes || '0');
|
|
50
|
+
const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
|
|
51
|
+
if (maxBytes === 0 || used < maxBytes) return BigInt(bs.id);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve LCD node object to an HTTPS URL.
|
|
58
|
+
* LCD v3 returns `remote_addrs: ["IP:PORT"]` (array, NO protocol prefix).
|
|
59
|
+
* Legacy responses may have `remote_url: "https://IP:PORT"` (string with prefix).
|
|
60
|
+
* This handles both formats.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveNodeUrl(node) {
|
|
63
|
+
// Try legacy field first (string with https://)
|
|
64
|
+
if (node.remote_url && typeof node.remote_url === 'string') return node.remote_url;
|
|
65
|
+
// v3 LCD: remote_addrs is an array of "IP:PORT" strings
|
|
66
|
+
const addrs = node.remote_addrs || [];
|
|
67
|
+
const raw = addrs.find(a => a.includes(':')) || addrs[0];
|
|
68
|
+
if (!raw) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${node.address} has no remote_addrs`, { address: node.address });
|
|
69
|
+
return raw.startsWith('http') ? raw : `https://${raw}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Node List Cache ────────────────────────────────────────────────────────
|
|
73
|
+
let _nodeListCache = null;
|
|
74
|
+
let _nodeListCacheAt = 0;
|
|
75
|
+
const NODE_CACHE_TTL = 5 * 60_000; // 5 minutes
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Invalidate the node list cache. Call after operations that change the node set.
|
|
79
|
+
*/
|
|
80
|
+
export function invalidateNodeCache() { _nodeListCache = null; }
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fetch all active nodes from LCD with pagination.
|
|
84
|
+
* Returns array of node objects. Each node has:
|
|
85
|
+
* - `remote_url`: the first usable HTTPS URL (for primary connection)
|
|
86
|
+
* - `remoteAddrs`: ALL remote addresses (for fallback on connection failure)
|
|
87
|
+
*
|
|
88
|
+
* Results are cached for 5 minutes. Call invalidateNodeCache() to force refresh.
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
|
|
91
|
+
// Return cached copy if fresh
|
|
92
|
+
if (_nodeListCache && (Date.now() - _nodeListCacheAt) < NODE_CACHE_TTL) {
|
|
93
|
+
return _nodeListCache.map(n => ({ ...n, planIds: [...(n.planIds || [])] }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { items } = await lcdPaginatedSafe(lcdUrl, '/sentinel/node/v3/nodes?status=1', 'nodes', { limit });
|
|
97
|
+
for (const n of items) {
|
|
98
|
+
// Preserve ALL remote addresses for fallback
|
|
99
|
+
const addrs = n.remote_addrs || [];
|
|
100
|
+
n.remoteAddrs = addrs.map(a => a.startsWith('http') ? a : `https://${a}`);
|
|
101
|
+
try { n.remote_url = resolveNodeUrl(n); } catch { /* skip nodes with no address */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Cache with deep copy
|
|
105
|
+
_nodeListCache = items;
|
|
106
|
+
_nodeListCacheAt = Date.now();
|
|
107
|
+
return items.map(n => ({ ...n, planIds: [...(n.planIds || [])] }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get a quick network overview — total nodes, counts by country and service type, average prices.
|
|
112
|
+
* Perfect for dashboard UIs, onboarding screens, and network health displays.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} [lcdUrl] - LCD endpoint (default: cascading fallback)
|
|
115
|
+
* @returns {Promise<{ totalNodes: number, byCountry: Array<{country: string, count: number}>, byType: {wireguard: number, v2ray: number, unknown: number}, averagePrice: {gigabyteDvpn: number, hourlyDvpn: number}, nodes: Array }>}
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* const overview = await getNetworkOverview();
|
|
119
|
+
* console.log(`${overview.totalNodes} nodes across ${overview.byCountry.length} countries`);
|
|
120
|
+
* console.log(`Average: ${overview.averagePrice.gigabyteDvpn.toFixed(3)} P2P/GB`);
|
|
121
|
+
*/
|
|
122
|
+
export async function getNetworkOverview(lcdUrl) {
|
|
123
|
+
let nodes;
|
|
124
|
+
if (lcdUrl) {
|
|
125
|
+
nodes = await fetchActiveNodes(lcdUrl);
|
|
126
|
+
} else {
|
|
127
|
+
const result = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, 'getNetworkOverview');
|
|
128
|
+
nodes = result.result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Filter to nodes that accept udvpn
|
|
132
|
+
const active = nodes.filter(n => n.remote_url && (n.gigabyte_prices || []).some(p => p.denom === 'udvpn'));
|
|
133
|
+
|
|
134
|
+
// Count by country (from LCD metadata, limited — enrichNodes gives better data)
|
|
135
|
+
const countryMap = {};
|
|
136
|
+
for (const n of active) {
|
|
137
|
+
const c = n.location?.country || n.country || 'Unknown';
|
|
138
|
+
countryMap[c] = (countryMap[c] || 0) + 1;
|
|
139
|
+
}
|
|
140
|
+
const byCountry = Object.entries(countryMap)
|
|
141
|
+
.map(([country, count]) => ({ country, count }))
|
|
142
|
+
.sort((a, b) => b.count - a.count);
|
|
143
|
+
|
|
144
|
+
// Count by type (type not in LCD — estimate from service_type field if present)
|
|
145
|
+
const byType = { wireguard: 0, v2ray: 0, unknown: 0 };
|
|
146
|
+
for (const n of active) {
|
|
147
|
+
const t = n.service_type || n.type;
|
|
148
|
+
if (t === 'wireguard' || t === 1) byType.wireguard++;
|
|
149
|
+
else if (t === 'v2ray' || t === 2) byType.v2ray++;
|
|
150
|
+
else byType.unknown++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Average prices
|
|
154
|
+
let gbTotal = 0, gbCount = 0, hrTotal = 0, hrCount = 0;
|
|
155
|
+
for (const n of active) {
|
|
156
|
+
const gb = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
|
|
157
|
+
if (gb?.quote_value) { gbTotal += parseInt(gb.quote_value, 10); gbCount++; }
|
|
158
|
+
const hr = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
|
|
159
|
+
if (hr?.quote_value) { hrTotal += parseInt(hr.quote_value, 10); hrCount++; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
totalNodes: active.length,
|
|
164
|
+
byCountry,
|
|
165
|
+
byType,
|
|
166
|
+
averagePrice: {
|
|
167
|
+
gigabyteDvpn: gbCount > 0 ? (gbTotal / gbCount) / 1_000_000 : 0,
|
|
168
|
+
hourlyDvpn: hrCount > 0 ? (hrTotal / hrCount) / 1_000_000 : 0,
|
|
169
|
+
},
|
|
170
|
+
nodes: active,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Discover plan IDs by probing subscription endpoints.
|
|
176
|
+
* Workaround for /sentinel/plan/v3/plans returning 501 Not Implemented.
|
|
177
|
+
* Returns sorted array of plan IDs that have at least 1 subscription.
|
|
178
|
+
*/
|
|
179
|
+
export async function discoverPlanIds(lcdUrl, maxId = 500) {
|
|
180
|
+
// Delegates to discoverPlans and extracts just the IDs
|
|
181
|
+
const plans = await discoverPlans(lcdUrl, { maxId });
|
|
182
|
+
return plans.map(p => p.id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get standardized prices for a node — abstracts V3 LCD price parsing entirely.
|
|
187
|
+
*
|
|
188
|
+
* Solves the common "NaN / GB" problem by defensively extracting quote_value,
|
|
189
|
+
* base_value, or amount from the nested LCD response structure.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} nodeAddress - sentnode1... address
|
|
192
|
+
* @param {string} [lcdUrl] - LCD endpoint URL (default: cascading fallback across all endpoints)
|
|
193
|
+
* @returns {Promise<{ gigabyte: { dvpn: number, udvpn: number, raw: object|null }, hourly: { dvpn: number, udvpn: number, raw: object|null }, denom: string, nodeAddress: string }>}
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* const prices = await getNodePrices('sentnode1abc...');
|
|
197
|
+
* console.log(`${prices.gigabyte.dvpn} P2P/GB, ${prices.hourly.dvpn} P2P/hr`);
|
|
198
|
+
* // Use prices.gigabyte.raw for the full { denom, base_value, quote_value } object
|
|
199
|
+
* // needed by encodeMsgStartSession's max_price field.
|
|
200
|
+
*/
|
|
201
|
+
export async function getNodePrices(nodeAddress, lcdUrl) {
|
|
202
|
+
if (typeof nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(nodeAddress)) {
|
|
203
|
+
throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (46 characters)', { value: nodeAddress });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Reuse queryNode() instead of duplicating pagination
|
|
207
|
+
const node = await queryNode(nodeAddress, { lcdUrl });
|
|
208
|
+
|
|
209
|
+
function extractPrice(priceArray) {
|
|
210
|
+
if (!Array.isArray(priceArray)) return { dvpn: 0, udvpn: 0, raw: null };
|
|
211
|
+
const entry = priceArray.find(p => p.denom === 'udvpn');
|
|
212
|
+
if (!entry) return { dvpn: 0, udvpn: 0, raw: null };
|
|
213
|
+
// Defensive fallback chain: quote_value (V3 current) → base_value → amount (legacy)
|
|
214
|
+
const rawVal = entry.quote_value || entry.base_value || entry.amount || '0';
|
|
215
|
+
const udvpn = parseInt(rawVal, 10) || 0;
|
|
216
|
+
return { dvpn: parseFloat((udvpn / 1_000_000).toFixed(6)), udvpn, raw: entry };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
gigabyte: extractPrice(node.gigabyte_prices),
|
|
221
|
+
hourly: extractPrice(node.hourly_prices),
|
|
222
|
+
denom: 'P2P',
|
|
223
|
+
nodeAddress,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── v26c: Session & Subscription Queries ────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Query a wallet's active subscriptions.
|
|
231
|
+
* @param {string} lcdUrl
|
|
232
|
+
* @param {string} walletAddr - sent1... address
|
|
233
|
+
* @returns {Promise<{ subscriptions: any[], total: number|null }>}
|
|
234
|
+
*/
|
|
235
|
+
export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
|
|
236
|
+
// v26: Correct LCD endpoint for wallet subscriptions
|
|
237
|
+
let path = `/sentinel/subscription/v3/accounts/${walletAddr}/subscriptions`;
|
|
238
|
+
if (opts.status) path += `?status=${opts.status === 'active' ? '1' : '2'}`;
|
|
239
|
+
return lcdQueryAll(path, { lcdUrl, dataKey: 'subscriptions' });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Query a single session directly by ID — O(1) instead of scanning all wallet sessions.
|
|
244
|
+
* Returns the flattened session object or null if not found.
|
|
245
|
+
* Use this when you know the session ID (e.g., from batch TX events).
|
|
246
|
+
*
|
|
247
|
+
* @param {string} lcdUrl - LCD endpoint URL
|
|
248
|
+
* @param {string|number|bigint} sessionId - Session ID to query
|
|
249
|
+
* @returns {Promise<object|null>} Flattened session object, or null if not found
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* const session = await querySessionById('https://lcd.sentinel.co', 123456);
|
|
253
|
+
* if (session) console.log(`Session ${session.id} on node ${session.node_address}`);
|
|
254
|
+
*/
|
|
255
|
+
export async function querySessionById(lcdUrl, sessionId) {
|
|
256
|
+
try {
|
|
257
|
+
const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
|
|
258
|
+
const raw = data?.session;
|
|
259
|
+
if (!raw) return null;
|
|
260
|
+
return flattenSession(raw);
|
|
261
|
+
} catch { return null; }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Query session allocation (remaining bandwidth).
|
|
266
|
+
* @param {string} lcdUrl
|
|
267
|
+
* @param {string|number|bigint} sessionId
|
|
268
|
+
* @returns {Promise<{ maxBytes: number, usedBytes: number, remainingBytes: number, percentUsed: number }|null>}
|
|
269
|
+
*/
|
|
270
|
+
export async function querySessionAllocation(lcdUrl, sessionId) {
|
|
271
|
+
try {
|
|
272
|
+
const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
|
|
273
|
+
const s = data.session?.base_session || data.session || {};
|
|
274
|
+
const maxBytes = parseInt(s.max_bytes || '0', 10);
|
|
275
|
+
const dl = parseInt(s.download_bytes || '0', 10);
|
|
276
|
+
const ul = parseInt(s.upload_bytes || '0', 10);
|
|
277
|
+
const usedBytes = dl + ul;
|
|
278
|
+
return {
|
|
279
|
+
maxBytes,
|
|
280
|
+
usedBytes,
|
|
281
|
+
remainingBytes: Math.max(0, maxBytes - usedBytes),
|
|
282
|
+
percentUsed: maxBytes > 0 ? Math.round((usedBytes / maxBytes) * 100) : 0,
|
|
283
|
+
};
|
|
284
|
+
} catch { return null; }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fetch a single node by address from LCD (no need to fetch all 1000+ nodes).
|
|
289
|
+
* Tries the direct v3 endpoint first, falls back to paginated search.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} nodeAddress - sentnode1... address
|
|
292
|
+
* @param {object} [opts]
|
|
293
|
+
* @param {string} [opts.lcdUrl] - LCD endpoint (or uses fallback chain)
|
|
294
|
+
* @returns {Promise<object>} Node object with remote_url resolved
|
|
295
|
+
*/
|
|
296
|
+
export async function queryNode(nodeAddress, opts = {}) {
|
|
297
|
+
if (typeof nodeAddress !== 'string' || !nodeAddress.startsWith('sentnode1')) {
|
|
298
|
+
throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be sentnode1...', { nodeAddress });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const fetchDirect = async (baseUrl) => {
|
|
302
|
+
try {
|
|
303
|
+
const data = await lcdQuery(`/sentinel/node/v3/nodes/${nodeAddress}`, { lcdUrl: baseUrl });
|
|
304
|
+
if (data?.node) {
|
|
305
|
+
data.node.remote_url = resolveNodeUrl(data.node);
|
|
306
|
+
return data.node;
|
|
307
|
+
}
|
|
308
|
+
} catch { /* fall through to full list */ }
|
|
309
|
+
const { items } = await lcdPaginatedSafe(baseUrl, '/sentinel/node/v3/nodes?status=1', 'nodes');
|
|
310
|
+
const found = items.find(n => n.address === nodeAddress);
|
|
311
|
+
if (!found) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found on LCD (may be inactive)`, { nodeAddress });
|
|
312
|
+
found.remote_url = resolveNodeUrl(found);
|
|
313
|
+
return found;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
if (opts.lcdUrl) return fetchDirect(opts.lcdUrl);
|
|
317
|
+
const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `LCD node lookup ${nodeAddress}`);
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* List all sessions for a wallet address.
|
|
323
|
+
* @param {string} address - sent1... wallet address
|
|
324
|
+
* @param {string} [lcdUrl]
|
|
325
|
+
* @param {object} [opts]
|
|
326
|
+
* @param {string} [opts.status] - '1' (active) or '2' (inactive)
|
|
327
|
+
* @returns {Promise<{ items: ChainSession[], total: number }>}
|
|
328
|
+
*/
|
|
329
|
+
export async function querySessions(address, lcdUrl, opts = {}) {
|
|
330
|
+
let path = `/sentinel/session/v3/sessions?address=${address}`;
|
|
331
|
+
if (opts.status) path += `&status=${opts.status}`;
|
|
332
|
+
const result = await lcdPaginatedSafe(lcdUrl, path, 'sessions');
|
|
333
|
+
// Auto-flatten base_session nesting so devs don't hit session.id === undefined
|
|
334
|
+
result.items = result.items.map(flattenSession);
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Flatten a chain session's base_session fields to top level.
|
|
340
|
+
* Prevents the #1 footgun: `session.id === undefined` (data is nested under base_session).
|
|
341
|
+
* Preserves `price` (node sessions) and `subscription_id` (plan sessions).
|
|
342
|
+
*
|
|
343
|
+
* @param {object} session - Raw LCD session object
|
|
344
|
+
* @returns {object} Flattened session with all fields at top level
|
|
345
|
+
*/
|
|
346
|
+
export function flattenSession(session) {
|
|
347
|
+
if (!session) return session;
|
|
348
|
+
const bs = session.base_session || {};
|
|
349
|
+
return {
|
|
350
|
+
id: bs.id || session.id,
|
|
351
|
+
acc_address: bs.acc_address || session.acc_address,
|
|
352
|
+
node_address: bs.node_address || bs.node || session.node_address,
|
|
353
|
+
download_bytes: bs.download_bytes || session.download_bytes || '0',
|
|
354
|
+
upload_bytes: bs.upload_bytes || session.upload_bytes || '0',
|
|
355
|
+
max_bytes: bs.max_bytes || session.max_bytes || '0',
|
|
356
|
+
duration: bs.duration || session.duration,
|
|
357
|
+
max_duration: bs.max_duration || session.max_duration,
|
|
358
|
+
status: bs.status || session.status,
|
|
359
|
+
start_at: bs.start_at || session.start_at,
|
|
360
|
+
status_at: bs.status_at || session.status_at,
|
|
361
|
+
inactive_at: bs.inactive_at || session.inactive_at,
|
|
362
|
+
// Preserve type-specific fields
|
|
363
|
+
price: session.price || undefined,
|
|
364
|
+
subscription_id: session.subscription_id || undefined,
|
|
365
|
+
'@type': session['@type'] || undefined,
|
|
366
|
+
_raw: session, // original for advanced use
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Get a single subscription by ID.
|
|
372
|
+
* @param {string|number} id - Subscription ID
|
|
373
|
+
* @param {string} [lcdUrl]
|
|
374
|
+
* @returns {Promise<Subscription|null>}
|
|
375
|
+
*/
|
|
376
|
+
export async function querySubscription(id, lcdUrl) {
|
|
377
|
+
try {
|
|
378
|
+
const data = await lcdQuery(`/sentinel/subscription/v3/subscriptions/${id}`, { lcdUrl });
|
|
379
|
+
return data.subscription || null;
|
|
380
|
+
} catch { return null; }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Check if wallet has an active subscription for a specific plan.
|
|
385
|
+
* @param {string} address - sent1... wallet address
|
|
386
|
+
* @param {number|string} planId - Plan ID to check
|
|
387
|
+
* @param {string} [lcdUrl]
|
|
388
|
+
* @returns {Promise<{ has: boolean, subscription?: object }>}
|
|
389
|
+
*/
|
|
390
|
+
export async function hasActiveSubscription(address, planId, lcdUrl) {
|
|
391
|
+
const { items } = await querySubscriptions(lcdUrl, address, { status: 'active' });
|
|
392
|
+
const match = items.find(s => String(s.plan_id) === String(planId));
|
|
393
|
+
if (match) return { has: true, subscription: match };
|
|
394
|
+
return { has: false };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── Plan Subscriber Helpers (v25b) ──────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Query all subscriptions for a plan. Supports owner filtering.
|
|
401
|
+
*
|
|
402
|
+
* @param {number|string} planId - Plan ID
|
|
403
|
+
* @param {object} [opts]
|
|
404
|
+
* @param {string} [opts.lcdUrl] - LCD endpoint
|
|
405
|
+
* @param {string} [opts.excludeAddress] - Filter out this address (typically the plan owner)
|
|
406
|
+
* @returns {Promise<{ subscribers: Array<{ address: string, status: number, id: string }>, total: number|null }>}
|
|
407
|
+
*/
|
|
408
|
+
export async function queryPlanSubscribers(planId, opts = {}) {
|
|
409
|
+
const { items, total } = await lcdQueryAll(
|
|
410
|
+
`/sentinel/subscription/v3/plans/${planId}/subscriptions`,
|
|
411
|
+
{ lcdUrl: opts.lcdUrl, dataKey: 'subscriptions' },
|
|
412
|
+
);
|
|
413
|
+
let subscribers = items.map(s => ({
|
|
414
|
+
address: s.address || s.subscriber,
|
|
415
|
+
status: s.status,
|
|
416
|
+
id: s.id || s.base_id,
|
|
417
|
+
...s,
|
|
418
|
+
}));
|
|
419
|
+
if (opts.excludeAddress) {
|
|
420
|
+
subscribers = subscribers.filter(s => s.address !== opts.excludeAddress);
|
|
421
|
+
}
|
|
422
|
+
return { subscribers, total };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Get plan stats with self-subscription filtered out.
|
|
427
|
+
*
|
|
428
|
+
* @param {number|string} planId
|
|
429
|
+
* @param {string} ownerAddress - Plan owner's sent1... address (filtered from counts)
|
|
430
|
+
* @param {object} [opts]
|
|
431
|
+
* @param {string} [opts.lcdUrl]
|
|
432
|
+
* @returns {Promise<{ subscriberCount: number, totalOnChain: number, ownerSubscribed: boolean }>}
|
|
433
|
+
*/
|
|
434
|
+
export async function getPlanStats(planId, ownerAddress, opts = {}) {
|
|
435
|
+
const { subscribers, total } = await queryPlanSubscribers(planId, { lcdUrl: opts.lcdUrl });
|
|
436
|
+
const ownerSubscribed = subscribers.some(s => s.address === ownerAddress);
|
|
437
|
+
const filtered = subscribers.filter(s => s.address !== ownerAddress);
|
|
438
|
+
return {
|
|
439
|
+
subscriberCount: filtered.length,
|
|
440
|
+
totalOnChain: total,
|
|
441
|
+
ownerSubscribed,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── v26: Field Experience Helpers ────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Query all nodes linked to a plan.
|
|
449
|
+
* @param {number|string} planId
|
|
450
|
+
* @param {string} [lcdUrl]
|
|
451
|
+
* @returns {Promise<{ items: any[], total: number|null }>}
|
|
452
|
+
*/
|
|
453
|
+
export async function queryPlanNodes(planId, lcdUrl) {
|
|
454
|
+
// LCD pagination is BROKEN on this endpoint — count_total returns min(actual, limit)
|
|
455
|
+
// and next_key is always null. Single high-limit request gets all nodes.
|
|
456
|
+
const doQuery = async (baseUrl) => {
|
|
457
|
+
const data = await lcd(baseUrl, `/sentinel/node/v3/plans/${planId}/nodes?pagination.limit=5000`);
|
|
458
|
+
return { items: data.nodes || [], total: (data.nodes || []).length };
|
|
459
|
+
};
|
|
460
|
+
if (lcdUrl) return doQuery(lcdUrl);
|
|
461
|
+
const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD plan ${planId} nodes`);
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Discover all available plans with metadata (subscriber count, node count, price).
|
|
467
|
+
* Probes plan IDs 1-maxId, returns plans with >=1 subscriber.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} [lcdUrl]
|
|
470
|
+
* @param {object} [opts]
|
|
471
|
+
* @param {number} [opts.maxId=500] - Highest plan ID to probe
|
|
472
|
+
* @param {number} [opts.batchSize=15] - Parallel probes per batch
|
|
473
|
+
* @param {boolean} [opts.includeEmpty=false] - Include plans with 0 nodes
|
|
474
|
+
* @returns {Promise<Array<{ id: number, subscribers: number, nodeCount: number, price: object|null, hasNodes: boolean }>>}
|
|
475
|
+
*/
|
|
476
|
+
export async function discoverPlans(lcdUrl, opts = {}) {
|
|
477
|
+
const maxId = opts.maxId || 500;
|
|
478
|
+
const batchSize = opts.batchSize || 15;
|
|
479
|
+
const includeEmpty = opts.includeEmpty || false;
|
|
480
|
+
const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
|
|
481
|
+
const plans = [];
|
|
482
|
+
|
|
483
|
+
for (let batch = 0; batch < Math.ceil(maxId / batchSize); batch++) {
|
|
484
|
+
const probes = [];
|
|
485
|
+
for (let i = batch * batchSize + 1; i <= Math.min((batch + 1) * batchSize, maxId); i++) {
|
|
486
|
+
probes.push((async (id) => {
|
|
487
|
+
try {
|
|
488
|
+
const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
|
|
489
|
+
const subCount = parseInt(subData.pagination?.total || '0', 10);
|
|
490
|
+
if (subCount === 0 && !includeEmpty) return null;
|
|
491
|
+
// Plan nodes endpoint has broken pagination (count_total wrong, next_key null).
|
|
492
|
+
// Use limit=5000 single request and count the actual array.
|
|
493
|
+
const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
|
|
494
|
+
const nodeCount = (nodeData.nodes || []).length;
|
|
495
|
+
const price = subData.subscriptions?.[0]?.price || null;
|
|
496
|
+
return { id, subscribers: subCount, nodeCount, price, hasNodes: nodeCount > 0 };
|
|
497
|
+
} catch { return null; }
|
|
498
|
+
})(i));
|
|
499
|
+
}
|
|
500
|
+
const results = await Promise.all(probes);
|
|
501
|
+
for (const r of results) if (r) plans.push(r);
|
|
502
|
+
}
|
|
503
|
+
return plans.sort((a, b) => a.id - b.id);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Get provider details by address.
|
|
509
|
+
* @param {string} provAddress - sentprov1... address
|
|
510
|
+
* @param {object} [opts]
|
|
511
|
+
* @param {string} [opts.lcdUrl]
|
|
512
|
+
* @returns {Promise<object|null>}
|
|
513
|
+
*/
|
|
514
|
+
export async function getProviderByAddress(provAddress, opts = {}) {
|
|
515
|
+
try {
|
|
516
|
+
const data = await lcdQuery(`/sentinel/provider/v2/providers/${provAddress}`, opts);
|
|
517
|
+
return data.provider || null;
|
|
518
|
+
} catch { return null; }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── VPN Settings Persistence ────────────────────────────────────────────────
|
|
522
|
+
// v27: Persistent user settings (backported from C# VpnSettings.cs).
|
|
523
|
+
// Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.
|
|
524
|
+
|
|
525
|
+
const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Load persisted VPN settings from disk.
|
|
529
|
+
* Returns empty object if file doesn't exist or is corrupt.
|
|
530
|
+
* @returns {Record<string, any>}
|
|
531
|
+
*/
|
|
532
|
+
export function loadVpnSettings() {
|
|
533
|
+
try {
|
|
534
|
+
if (!existsSync(SETTINGS_FILE)) return {};
|
|
535
|
+
return JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8'));
|
|
536
|
+
} catch { return {}; }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Save VPN settings to disk. Creates ~/.sentinel-sdk/ if needed.
|
|
541
|
+
* @param {Record<string, any>} settings
|
|
542
|
+
*/
|
|
543
|
+
export function saveVpnSettings(settings) {
|
|
544
|
+
const dir = path.dirname(SETTINGS_FILE);
|
|
545
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
546
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
547
|
+
}
|