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.
Files changed (215) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/ai-path/ADMIN-ELEVATION.md +116 -0
  5. package/ai-path/AI-MANIFESTO.md +185 -0
  6. package/ai-path/BREAKING.md +74 -0
  7. package/ai-path/CHECKLIST.md +619 -0
  8. package/ai-path/CONNECTION-STEPS.md +724 -0
  9. package/ai-path/DECISION-TREE.md +378 -0
  10. package/ai-path/DEPENDENCIES.md +459 -0
  11. package/ai-path/E2E-FLOW.md +1555 -0
  12. package/ai-path/FAILURES.md +403 -0
  13. package/ai-path/GUIDE.md +1217 -0
  14. package/ai-path/README.md +558 -0
  15. package/ai-path/SPLIT-TUNNEL.md +266 -0
  16. package/ai-path/cli.js +535 -0
  17. package/ai-path/connect.js +884 -0
  18. package/ai-path/discover.js +178 -0
  19. package/ai-path/environment.js +266 -0
  20. package/ai-path/errors.js +86 -0
  21. package/ai-path/examples/autonomous-agent.mjs +220 -0
  22. package/ai-path/examples/multi-region.mjs +174 -0
  23. package/ai-path/examples/one-shot.mjs +31 -0
  24. package/ai-path/index.js +60 -0
  25. package/ai-path/pricing.js +136 -0
  26. package/ai-path/recommend.js +413 -0
  27. package/ai-path/run-admin.vbs +25 -0
  28. package/ai-path/setup.js +291 -0
  29. package/ai-path/wallet.js +137 -0
  30. package/app-helpers.js +363 -0
  31. package/app-settings.js +95 -0
  32. package/app-types.js +267 -0
  33. package/audit.js +847 -0
  34. package/batch.js +293 -0
  35. package/bin/setup.js +376 -0
  36. package/chain/authz.js +109 -0
  37. package/chain/broadcast.js +472 -0
  38. package/chain/client.js +160 -0
  39. package/chain/fee-grants.js +305 -0
  40. package/chain/index.js +891 -0
  41. package/chain/lcd.js +313 -0
  42. package/chain/queries.js +547 -0
  43. package/chain/rpc.js +408 -0
  44. package/chain/wallet.js +141 -0
  45. package/cli/config.js +143 -0
  46. package/cli/index.js +463 -0
  47. package/cli/output.js +182 -0
  48. package/cli.js +491 -0
  49. package/client/index.js +251 -0
  50. package/client.js +271 -0
  51. package/config/index.js +255 -0
  52. package/connection/connect.js +849 -0
  53. package/connection/disconnect.js +180 -0
  54. package/connection/discovery.js +321 -0
  55. package/connection/index.js +76 -0
  56. package/connection/proxy.js +148 -0
  57. package/connection/resilience.js +428 -0
  58. package/connection/security.js +232 -0
  59. package/connection/state.js +369 -0
  60. package/connection/tunnel.js +691 -0
  61. package/consumer.js +132 -0
  62. package/cosmjs-setup.js +1884 -0
  63. package/defaults.js +366 -0
  64. package/disk-cache.js +107 -0
  65. package/dist/client.d.ts +108 -0
  66. package/dist/client.d.ts.map +1 -0
  67. package/dist/client.js +400 -0
  68. package/dist/client.js.map +1 -0
  69. package/dist/index.d.ts +8 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +8 -0
  72. package/dist/index.js.map +1 -0
  73. package/errors/index.js +112 -0
  74. package/errors.js +218 -0
  75. package/examples/README.md +64 -0
  76. package/examples/connect-direct.mjs +106 -0
  77. package/examples/connect-plan.mjs +125 -0
  78. package/examples/error-handling.mjs +109 -0
  79. package/examples/query-nodes.mjs +94 -0
  80. package/examples/wallet-basics.mjs +61 -0
  81. package/generated/amino/amino.ts +9 -0
  82. package/generated/cosmos/base/v1beta1/coin.ts +365 -0
  83. package/generated/cosmos_proto/cosmos.ts +323 -0
  84. package/generated/gogoproto/gogo.ts +9 -0
  85. package/generated/google/protobuf/descriptor.ts +7601 -0
  86. package/generated/google/protobuf/duration.ts +208 -0
  87. package/generated/google/protobuf/timestamp.ts +238 -0
  88. package/generated/sentinel/lease/v1/events.ts +924 -0
  89. package/generated/sentinel/lease/v1/lease.ts +292 -0
  90. package/generated/sentinel/lease/v1/msg.ts +949 -0
  91. package/generated/sentinel/lease/v1/params.ts +164 -0
  92. package/generated/sentinel/node/v3/events.ts +881 -0
  93. package/generated/sentinel/node/v3/msg.ts +1002 -0
  94. package/generated/sentinel/node/v3/node.ts +263 -0
  95. package/generated/sentinel/node/v3/params.ts +183 -0
  96. package/generated/sentinel/plan/v3/events.ts +675 -0
  97. package/generated/sentinel/plan/v3/msg.ts +1191 -0
  98. package/generated/sentinel/plan/v3/plan.ts +283 -0
  99. package/generated/sentinel/provider/v2/events.ts +171 -0
  100. package/generated/sentinel/provider/v2/msg.ts +480 -0
  101. package/generated/sentinel/provider/v2/params.ts +131 -0
  102. package/generated/sentinel/provider/v2/provider.ts +246 -0
  103. package/generated/sentinel/session/v3/events.ts +480 -0
  104. package/generated/sentinel/session/v3/msg.ts +616 -0
  105. package/generated/sentinel/session/v3/params.ts +260 -0
  106. package/generated/sentinel/session/v3/proof.ts +180 -0
  107. package/generated/sentinel/session/v3/session.ts +384 -0
  108. package/generated/sentinel/subscription/v3/events.ts +1181 -0
  109. package/generated/sentinel/subscription/v3/msg.ts +1305 -0
  110. package/generated/sentinel/subscription/v3/params.ts +167 -0
  111. package/generated/sentinel/subscription/v3/subscription.ts +315 -0
  112. package/generated/sentinel/types/v1/bandwidth.ts +124 -0
  113. package/generated/sentinel/types/v1/price.ts +149 -0
  114. package/generated/sentinel/types/v1/renewal.ts +87 -0
  115. package/generated/sentinel/types/v1/status.ts +54 -0
  116. package/generated/typeRegistry.ts +27 -0
  117. package/index.js +486 -0
  118. package/node-connect.js +3015 -0
  119. package/operator.js +134 -0
  120. package/package.json +113 -0
  121. package/plan-operations.js +199 -0
  122. package/preflight.js +352 -0
  123. package/pricing/index.js +262 -0
  124. package/proto/amino/amino.proto +84 -0
  125. package/proto/cosmos/base/v1beta1/coin.proto +61 -0
  126. package/proto/cosmos_proto/cosmos.proto +112 -0
  127. package/proto/gogoproto/gogo.proto +145 -0
  128. package/proto/google/api/annotations.proto +31 -0
  129. package/proto/google/api/http.proto +370 -0
  130. package/proto/google/protobuf/any.proto +106 -0
  131. package/proto/google/protobuf/duration.proto +115 -0
  132. package/proto/google/protobuf/timestamp.proto +145 -0
  133. package/proto/sentinel/lease/v1/events.proto +52 -0
  134. package/proto/sentinel/lease/v1/genesis.proto +15 -0
  135. package/proto/sentinel/lease/v1/lease.proto +25 -0
  136. package/proto/sentinel/lease/v1/msg.proto +62 -0
  137. package/proto/sentinel/lease/v1/params.proto +17 -0
  138. package/proto/sentinel/node/v3/events.proto +50 -0
  139. package/proto/sentinel/node/v3/genesis.proto +15 -0
  140. package/proto/sentinel/node/v3/msg.proto +63 -0
  141. package/proto/sentinel/node/v3/node.proto +27 -0
  142. package/proto/sentinel/node/v3/params.proto +21 -0
  143. package/proto/sentinel/node/v3/querier.proto +63 -0
  144. package/proto/sentinel/plan/v3/events.proto +41 -0
  145. package/proto/sentinel/plan/v3/genesis.proto +21 -0
  146. package/proto/sentinel/plan/v3/msg.proto +83 -0
  147. package/proto/sentinel/plan/v3/plan.proto +32 -0
  148. package/proto/sentinel/plan/v3/querier.proto +53 -0
  149. package/proto/sentinel/provider/v2/events.proto +16 -0
  150. package/proto/sentinel/provider/v2/genesis.proto +15 -0
  151. package/proto/sentinel/provider/v2/msg.proto +35 -0
  152. package/proto/sentinel/provider/v2/params.proto +17 -0
  153. package/proto/sentinel/provider/v2/provider.proto +24 -0
  154. package/proto/sentinel/provider/v3/genesis.proto +15 -0
  155. package/proto/sentinel/provider/v3/params.proto +13 -0
  156. package/proto/sentinel/session/v3/events.proto +30 -0
  157. package/proto/sentinel/session/v3/genesis.proto +15 -0
  158. package/proto/sentinel/session/v3/msg.proto +50 -0
  159. package/proto/sentinel/session/v3/params.proto +25 -0
  160. package/proto/sentinel/session/v3/proof.proto +25 -0
  161. package/proto/sentinel/session/v3/querier.proto +100 -0
  162. package/proto/sentinel/session/v3/session.proto +50 -0
  163. package/proto/sentinel/subscription/v2/allocation.proto +21 -0
  164. package/proto/sentinel/subscription/v2/payout.proto +22 -0
  165. package/proto/sentinel/subscription/v3/events.proto +65 -0
  166. package/proto/sentinel/subscription/v3/genesis.proto +17 -0
  167. package/proto/sentinel/subscription/v3/msg.proto +83 -0
  168. package/proto/sentinel/subscription/v3/params.proto +21 -0
  169. package/proto/sentinel/subscription/v3/subscription.proto +33 -0
  170. package/proto/sentinel/types/v1/bandwidth.proto +19 -0
  171. package/proto/sentinel/types/v1/price.proto +21 -0
  172. package/proto/sentinel/types/v1/renewal.proto +21 -0
  173. package/proto/sentinel/types/v1/status.proto +16 -0
  174. package/protocol/encoding.js +341 -0
  175. package/protocol/events.js +361 -0
  176. package/protocol/handshake.js +297 -0
  177. package/protocol/index.js +15 -0
  178. package/protocol/messages.js +346 -0
  179. package/protocol/plans.js +199 -0
  180. package/protocol/v2ray.js +268 -0
  181. package/protocol/v3.js +723 -0
  182. package/protocol/wireguard.js +125 -0
  183. package/security/index.js +132 -0
  184. package/session-manager.js +329 -0
  185. package/session-tracker.js +80 -0
  186. package/setup.js +376 -0
  187. package/speedtest/index.js +528 -0
  188. package/speedtest.js +567 -0
  189. package/src/client.ts +502 -0
  190. package/src/index.ts +20 -0
  191. package/state/index.js +347 -0
  192. package/state.js +516 -0
  193. package/test-all-chain-ops.js +493 -0
  194. package/test-all-logic.js +199 -0
  195. package/test-all-msg-types.js +292 -0
  196. package/test-every-connection.js +208 -0
  197. package/test-feegrant-connect.js +98 -0
  198. package/test-logic.js +148 -0
  199. package/test-mainnet.js +176 -0
  200. package/test-plan-lifecycle.js +335 -0
  201. package/tls-trust.js +132 -0
  202. package/tsconfig.build.json +20 -0
  203. package/tsconfig.json +34 -0
  204. package/types/chain.d.ts +746 -0
  205. package/types/connection.d.ts +425 -0
  206. package/types/errors.d.ts +174 -0
  207. package/types/index.d.ts +1380 -0
  208. package/types/nodes.d.ts +187 -0
  209. package/types/pricing.d.ts +156 -0
  210. package/types/protocol.d.ts +332 -0
  211. package/types/session.d.ts +236 -0
  212. package/types/settings.d.ts +192 -0
  213. package/v3protocol.js +1053 -0
  214. package/wallet/index.js +153 -0
  215. package/wireguard.js +307 -0
@@ -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
+ }