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,1884 @@
1
+ /**
2
+ * CosmJS Wallet, Registry & Signing Setup for Sentinel dVPN
3
+ *
4
+ * Everything needed to create a wallet, sign transactions, and broadcast
5
+ * to the Sentinel chain. Registers ALL 13 Sentinel message types.
6
+ *
7
+ * Usage:
8
+ * import { createWallet, privKeyFromMnemonic, createClient, extractId } from './cosmjs-setup.js';
9
+ * const { wallet, account } = await createWallet(mnemonic);
10
+ * const privKey = await privKeyFromMnemonic(mnemonic);
11
+ * const client = await createClient('https://rpc.sentinel.co:443', wallet);
12
+ */
13
+
14
+ import { Bip39, EnglishMnemonic, Slip10, Slip10Curve, Random } from '@cosmjs/crypto';
15
+ import { makeCosmoshubPath } from '@cosmjs/amino';
16
+ import { DirectSecp256k1HdWallet, Registry } from '@cosmjs/proto-signing';
17
+ import { SigningStargateClient, GasPrice, defaultRegistryTypes } from '@cosmjs/stargate';
18
+ import { fromBech32, toBech32 } from '@cosmjs/encoding';
19
+ import { EventEmitter } from 'events';
20
+
21
+ // 4 encoders + protobuf primitives from v3protocol.js
22
+ import {
23
+ encodeMsgStartSession,
24
+ encodeMsgEndSession,
25
+ encodeMsgStartSubscription,
26
+ encodeMsgSubStartSession,
27
+ encodeMsgCancelSubscription,
28
+ encodeMsgRenewSubscription,
29
+ encodeMsgShareSubscription,
30
+ encodeMsgUpdateSubscription,
31
+ encodeMsgUpdateSession,
32
+ encodeMsgRegisterNode,
33
+ encodeMsgUpdateNodeDetails,
34
+ encodeMsgUpdateNodeStatus,
35
+ encodeMsgUpdatePlanDetails,
36
+ extractSessionId,
37
+ encodeVarint, protoString, protoInt64, protoEmbedded,
38
+ } from './v3protocol.js';
39
+
40
+ // 10 encoders from plan-operations.js (provider, plan, lease, plan-session)
41
+ import {
42
+ encodeMsgRegisterProvider,
43
+ encodeMsgUpdateProviderDetails,
44
+ encodeMsgUpdateProviderStatus,
45
+ encodeMsgCreatePlan,
46
+ encodeMsgUpdatePlanStatus,
47
+ encodeMsgLinkNode,
48
+ encodeMsgUnlinkNode,
49
+ encodeMsgPlanStartSession,
50
+ encodeMsgStartLease,
51
+ encodeMsgEndLease,
52
+ } from './plan-operations.js';
53
+ import { GAS_PRICE, LCD_ENDPOINTS, tryWithFallback } from './defaults.js';
54
+ import { ValidationError, NodeError, ChainError, ErrorCodes } from './errors.js';
55
+ import path from 'path';
56
+ import os from 'os';
57
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
58
+
59
+ // ─── Input Validation Helpers ────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Validate a BIP39 mnemonic string. Returns true if valid, false if not.
63
+ * Use this to enable/disable a "Connect" button in your UI.
64
+ *
65
+ * @param {string} mnemonic - The mnemonic to validate
66
+ * @returns {boolean} True if the mnemonic is a valid 12+ word string
67
+ *
68
+ * @example
69
+ * if (isMnemonicValid(userInput)) showConnectButton();
70
+ */
71
+ export function isMnemonicValid(mnemonic) {
72
+ if (typeof mnemonic !== 'string') return false;
73
+ const trimmed = mnemonic.trim();
74
+ if (trimmed.split(/\s+/).length < 12) return false;
75
+ try {
76
+ new EnglishMnemonic(trimmed);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function validateMnemonic(mnemonic, fnName) {
84
+ if (!isMnemonicValid(mnemonic)) {
85
+ throw new ValidationError(ErrorCodes.INVALID_MNEMONIC,
86
+ `${fnName}(): mnemonic must be a 12+ word BIP39 string`,
87
+ { wordCount: typeof mnemonic === 'string' ? mnemonic.trim().split(/\s+/).length : 0 });
88
+ }
89
+ }
90
+
91
+ function validateAddress(addr, prefix, fnName) {
92
+ if (typeof addr !== 'string' || !addr.startsWith(prefix)) {
93
+ throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS,
94
+ `${fnName}(): address must be a valid ${prefix}... bech32 string`,
95
+ { value: addr });
96
+ }
97
+ }
98
+
99
+ // ─── Wallet ──────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Create a Sentinel wallet from a BIP39 mnemonic.
103
+ * Returns { wallet, account } where account.address is the sent1... address.
104
+ */
105
+ export async function createWallet(mnemonic) {
106
+ validateMnemonic(mnemonic, 'createWallet');
107
+ const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: 'sent' });
108
+ const [account] = await wallet.getAccounts();
109
+ return { wallet, account };
110
+ }
111
+
112
+ /**
113
+ * Generate a new wallet with a fresh random BIP39 mnemonic.
114
+ * @param {number} strength - 128 for 12 words, 256 for 24 words (default: 128)
115
+ * @returns {{ mnemonic: string, wallet: DirectSecp256k1HdWallet, account: { address: string } }}
116
+ */
117
+ export async function generateWallet(strength = 128) {
118
+ const entropy = Random.getBytes(strength / 8);
119
+ const mnemonic = Bip39.encode(entropy).toString();
120
+ const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: 'sent' });
121
+ const [account] = await wallet.getAccounts();
122
+ return { mnemonic, wallet, account };
123
+ }
124
+
125
+ /**
126
+ * Derive the raw secp256k1 private key from a mnemonic.
127
+ * Needed for handshake signatures (node-handshake protocol).
128
+ */
129
+ export async function privKeyFromMnemonic(mnemonic) {
130
+ validateMnemonic(mnemonic, 'privKeyFromMnemonic');
131
+ const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(mnemonic));
132
+ const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, makeCosmoshubPath(0));
133
+ return Buffer.from(privkey);
134
+ }
135
+
136
+ // ─── Address Prefix Conversion ───────────────────────────────────────────────
137
+ // Same key, different bech32 prefix. See address-prefixes.md.
138
+
139
+ export function sentToSentprov(sentAddr) {
140
+ validateAddress(sentAddr, 'sent', 'sentToSentprov');
141
+ const { data } = fromBech32(sentAddr);
142
+ return toBech32('sentprov', data);
143
+ }
144
+
145
+ export function sentToSentnode(sentAddr) {
146
+ validateAddress(sentAddr, 'sent', 'sentToSentnode');
147
+ const { data } = fromBech32(sentAddr);
148
+ return toBech32('sentnode', data);
149
+ }
150
+
151
+ export function sentprovToSent(provAddr) {
152
+ validateAddress(provAddr, 'sentprov', 'sentprovToSent');
153
+ const { data } = fromBech32(provAddr);
154
+ return toBech32('sent', data);
155
+ }
156
+
157
+ // ─── CosmJS Registry ─────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Adapter that wraps a manual protobuf encoder for CosmJS's Registry.
161
+ * CosmJS expects { fromPartial, encode, decode } — we only need encode.
162
+ */
163
+ function makeMsgType(encodeFn) {
164
+ return {
165
+ fromPartial: (v) => v,
166
+ encode: (inst) => ({ finish: () => encodeFn(inst) }),
167
+ decode: () => ({}),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Build a CosmJS Registry with ALL 14 Sentinel message types registered.
173
+ * This is required for signAndBroadcast to encode Sentinel-specific messages.
174
+ */
175
+ export function buildRegistry() {
176
+ return new Registry([
177
+ ...defaultRegistryTypes,
178
+ // Direct node session (v3protocol.js)
179
+ ['/sentinel.node.v3.MsgStartSessionRequest', makeMsgType(encodeMsgStartSession)],
180
+ // End session (v3protocol.js)
181
+ ['/sentinel.session.v3.MsgCancelSessionRequest', makeMsgType(encodeMsgEndSession)],
182
+ // Subscription (v3protocol.js)
183
+ ['/sentinel.subscription.v3.MsgStartSubscriptionRequest', makeMsgType(encodeMsgStartSubscription)],
184
+ ['/sentinel.subscription.v3.MsgStartSessionRequest', makeMsgType(encodeMsgSubStartSession)],
185
+ // Plan (plan-operations.js)
186
+ ['/sentinel.plan.v3.MsgStartSessionRequest', makeMsgType(encodeMsgPlanStartSession)],
187
+ ['/sentinel.plan.v3.MsgCreatePlanRequest', makeMsgType(encodeMsgCreatePlan)],
188
+ ['/sentinel.plan.v3.MsgLinkNodeRequest', makeMsgType(encodeMsgLinkNode)],
189
+ ['/sentinel.plan.v3.MsgUnlinkNodeRequest', makeMsgType(encodeMsgUnlinkNode)],
190
+ ['/sentinel.plan.v3.MsgUpdatePlanStatusRequest', makeMsgType(encodeMsgUpdatePlanStatus)],
191
+ // Provider (plan-operations.js)
192
+ ['/sentinel.provider.v3.MsgRegisterProviderRequest', makeMsgType(encodeMsgRegisterProvider)],
193
+ ['/sentinel.provider.v3.MsgUpdateProviderDetailsRequest', makeMsgType(encodeMsgUpdateProviderDetails)],
194
+ ['/sentinel.provider.v3.MsgUpdateProviderStatusRequest', makeMsgType(encodeMsgUpdateProviderStatus)],
195
+ // Plan details update (v3 — NEW, from sentinel-go-sdk)
196
+ ['/sentinel.plan.v3.MsgUpdatePlanDetailsRequest', makeMsgType(encodeMsgUpdatePlanDetails)],
197
+ // Lease (plan-operations.js)
198
+ ['/sentinel.lease.v1.MsgStartLeaseRequest', makeMsgType(encodeMsgStartLease)],
199
+ ['/sentinel.lease.v1.MsgEndLeaseRequest', makeMsgType(encodeMsgEndLease)],
200
+ // Subscription management (v3 — from sentinel-go-sdk)
201
+ ['/sentinel.subscription.v3.MsgCancelSubscriptionRequest', makeMsgType(encodeMsgCancelSubscription)],
202
+ ['/sentinel.subscription.v3.MsgRenewSubscriptionRequest', makeMsgType(encodeMsgRenewSubscription)],
203
+ ['/sentinel.subscription.v3.MsgShareSubscriptionRequest', makeMsgType(encodeMsgShareSubscription)],
204
+ ['/sentinel.subscription.v3.MsgUpdateSubscriptionRequest', makeMsgType(encodeMsgUpdateSubscription)],
205
+ // Session management (v3)
206
+ ['/sentinel.session.v3.MsgUpdateSessionRequest', makeMsgType(encodeMsgUpdateSession)],
207
+ // Node operator (v3 — for node operators, NOT consumer apps)
208
+ ['/sentinel.node.v3.MsgRegisterNodeRequest', makeMsgType(encodeMsgRegisterNode)],
209
+ ['/sentinel.node.v3.MsgUpdateNodeDetailsRequest', makeMsgType(encodeMsgUpdateNodeDetails)],
210
+ ['/sentinel.node.v3.MsgUpdateNodeStatusRequest', makeMsgType(encodeMsgUpdateNodeStatus)],
211
+ ]);
212
+ }
213
+
214
+ // ─── Signing Client ──────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Create a SigningStargateClient connected to Sentinel RPC.
218
+ * Gas price: from defaults.js GAS_PRICE (chain minimum).
219
+ */
220
+ export async function createClient(rpcUrl, wallet) {
221
+ return SigningStargateClient.connectWithSigner(rpcUrl, wallet, {
222
+ gasPrice: GasPrice.fromString(GAS_PRICE),
223
+ registry: buildRegistry(),
224
+ });
225
+ }
226
+
227
+ // ─── TX Helpers ──────────────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Simple broadcast — send messages and return result.
231
+ * For production apps with multiple TXs, use createSafeBroadcaster() instead.
232
+ */
233
+ export async function broadcast(client, signerAddress, msgs, fee = null) {
234
+ if (!fee) fee = 'auto';
235
+ let result;
236
+ try {
237
+ result = await client.signAndBroadcast(signerAddress, msgs, fee);
238
+ } catch (err) {
239
+ // CosmJS on Node.js v18+ uses native fetch (undici) internally for RPC.
240
+ // Undici throws opaque "fetch failed" on network errors. Re-wrap with context.
241
+ const typeUrls = msgs.map(m => m.typeUrl).join(', ');
242
+ throw new ChainError(ErrorCodes.BROADCAST_FAILED, `Broadcast failed (${typeUrls}): ${err.message}`, { typeUrls, original: err.message });
243
+ }
244
+ if (result.code !== 0) throw new ChainError(ErrorCodes.TX_FAILED, `TX failed (code ${result.code}): ${result.rawLog}`, { code: result.code, rawLog: result.rawLog, txHash: result.transactionHash });
245
+ return result;
246
+ }
247
+
248
+ // ─── Safe Broadcast (Mutex + Retry + Sequence Recovery) ─────────────────────
249
+ // Production-critical: prevents sequence mismatch errors when sending
250
+ // multiple TXs rapidly (batch operations, auto-lease + link, UI clicks).
251
+
252
+ function isSequenceError(errOrStr) {
253
+ // Check Cosmos SDK error code 32 (ErrWrongSequence) first
254
+ if (errOrStr?.code === 32) return true;
255
+ const s = typeof errOrStr === 'string' ? errOrStr : errOrStr?.message || String(errOrStr);
256
+ // Try parsing rawLog as JSON to extract error code
257
+ try { const parsed = JSON.parse(s); if (parsed?.code === 32) return true; } catch {} // not JSON — fall through to string match
258
+ // Fallback to string match (last resort — fragile across Cosmos SDK upgrades)
259
+ return s && (s.includes('account sequence mismatch') || s.includes('incorrect account sequence'));
260
+ }
261
+
262
+ /**
263
+ * Create a safe broadcaster with mutex serialization and retry logic.
264
+ * Only one TX broadcasts at a time. Sequence errors trigger client reconnect + retry.
265
+ *
266
+ * Usage:
267
+ * const { safeBroadcast } = createSafeBroadcaster(rpcUrl, wallet, signerAddress);
268
+ * const result = await safeBroadcast([msg1, msg2]); // batch = one TX
269
+ */
270
+ export function createSafeBroadcaster(rpcUrl, wallet, signerAddress) {
271
+ let _client = null;
272
+ let _queue = Promise.resolve();
273
+
274
+ async function getClient() {
275
+ if (!_client) {
276
+ _client = await SigningStargateClient.connectWithSigner(rpcUrl, wallet, {
277
+ gasPrice: GasPrice.fromString(GAS_PRICE),
278
+ registry: buildRegistry(),
279
+ });
280
+ }
281
+ return _client;
282
+ }
283
+
284
+ async function resetClient() {
285
+ _client = await SigningStargateClient.connectWithSigner(rpcUrl, wallet, {
286
+ gasPrice: GasPrice.fromString(GAS_PRICE),
287
+ registry: buildRegistry(),
288
+ });
289
+ return _client;
290
+ }
291
+
292
+ async function _inner(msgs, memo) {
293
+ for (let attempt = 0; attempt < 5; attempt++) {
294
+ let client;
295
+ if (attempt === 0) {
296
+ client = await getClient();
297
+ } else {
298
+ const delay = Math.min(2000 * attempt, 6000);
299
+ await new Promise(r => setTimeout(r, delay));
300
+ client = await resetClient(); // fresh connection = fresh sequence
301
+ }
302
+
303
+ try {
304
+ const result = await client.signAndBroadcast(signerAddress, msgs, 'auto', memo);
305
+ if (result.code !== 0 && isSequenceError(result.rawLog)) continue;
306
+ return result;
307
+ } catch (err) {
308
+ if (isSequenceError(err.message)) continue;
309
+ throw err;
310
+ }
311
+ }
312
+ // Final attempt
313
+ await new Promise(r => setTimeout(r, 4000));
314
+ const client = await resetClient();
315
+ return client.signAndBroadcast(signerAddress, msgs, 'auto', memo);
316
+ }
317
+
318
+ function safeBroadcast(msgs, memo) {
319
+ const p = _queue.then(() => _inner(msgs, memo));
320
+ _queue = p.catch(() => {}); // don't break queue on failure
321
+ return p;
322
+ }
323
+
324
+ return { safeBroadcast, getClient, resetClient };
325
+ }
326
+
327
+ /**
328
+ * Parse chain error messages into user-friendly text.
329
+ * Covers all known Sentinel-specific error patterns.
330
+ */
331
+ export function parseChainError(raw) {
332
+ const s = String(raw || '');
333
+ if (s.includes('duplicate node for plan')) return 'Node is already in this plan';
334
+ if (s.includes('duplicate provider')) return 'Provider already registered — use Update';
335
+ if (s.includes('lease') && s.includes('not found')) return 'No active lease for this node';
336
+ if (s.includes('lease') && s.includes('already exists')) return 'Lease already exists for this node';
337
+ if (s.includes('insufficient funds')) return 'Insufficient P2P balance';
338
+ if (s.includes('invalid price')) return 'Price mismatch — node may have changed rates';
339
+ if (s.includes('invalid status inactive')) return 'Plan is inactive — activate first';
340
+ if (s.includes('plan') && s.includes('does not exist')) return 'Plan not found on chain';
341
+ if (s.includes('provider') && s.includes('does not exist')) return 'Provider not registered';
342
+ if (s.includes('node') && s.includes('does not exist')) return 'Node not found on chain';
343
+ if (s.includes('node') && s.includes('not active')) return 'Node is inactive';
344
+ if (s.includes('active session already exists')) return 'Session already exists for this node';
345
+ if (s.includes('subscription') && s.includes('not found')) return 'Subscription not found or expired';
346
+ if (s.includes('node address mismatch')) return 'Node address mismatch — wrong node at this URL';
347
+ if (s.includes('maximum peer limit')) return 'Node is full — maximum peer limit reached';
348
+ if (isSequenceError(s)) return 'Chain busy — sequence mismatch. Wait and retry.';
349
+ if (s.includes('out of gas')) return 'Transaction out of gas';
350
+ if (s.includes('timed out')) return 'Transaction timed out';
351
+ const m = s.match(/desc = (.+?)(?:\[|With gas|$)/);
352
+ if (m) return m[1].trim().slice(0, 120);
353
+ return s.slice(0, 150);
354
+ }
355
+
356
+ /**
357
+ * Extract an ID from TX ABCI events.
358
+ * Events may have base64-encoded keys/values depending on CosmJS version.
359
+ *
360
+ * Usage:
361
+ * extractId(result, /session/i, ['session_id', 'id'])
362
+ * extractId(result, /subscription/i, ['subscription_id', 'id'])
363
+ * extractId(result, /plan/i, ['plan_id', 'id'])
364
+ * extractId(result, /lease/i, ['lease_id', 'id'])
365
+ */
366
+ export function extractId(txResult, eventPattern, keyNames) {
367
+ for (const event of (txResult.events || [])) {
368
+ if (eventPattern.test(event.type)) {
369
+ for (const attr of event.attributes) {
370
+ const k = typeof attr.key === 'string'
371
+ ? attr.key
372
+ : Buffer.from(attr.key, 'base64').toString('utf8');
373
+ const v = typeof attr.value === 'string'
374
+ ? attr.value
375
+ : Buffer.from(attr.value, 'base64').toString('utf8');
376
+ if (keyNames.includes(k)) {
377
+ const p = v.replace(/"/g, '');
378
+ if (p && parseInt(p) > 0) return p;
379
+ }
380
+ }
381
+ }
382
+ }
383
+ return null;
384
+ }
385
+
386
+ // ─── LCD Query Helper ────────────────────────────────────────────────────────
387
+
388
+ import axios from 'axios';
389
+ import { publicEndpointAgent } from './tls-trust.js';
390
+
391
+ /**
392
+ * Query a Sentinel LCD REST endpoint.
393
+ * Checks both HTTP status AND gRPC error codes in response body.
394
+ * Uses CA-validated HTTPS for LCD public infrastructure (valid CA certs).
395
+ *
396
+ * Usage:
397
+ * const data = await lcd('https://lcd.sentinel.co', '/sentinel/node/v3/nodes?status=1');
398
+ */
399
+ export async function lcd(baseUrl, path) {
400
+ // Accept Endpoint objects ({ url, name }) or bare strings
401
+ const base = typeof baseUrl === 'object' ? baseUrl.url : baseUrl;
402
+ const url = `${base}${path}`;
403
+ const res = await axios.get(url, { httpsAgent: publicEndpointAgent, timeout: 15000 });
404
+ const data = res.data;
405
+ if (data?.code && data.code !== 0) {
406
+ throw new ChainError(ErrorCodes.LCD_ERROR, `LCD ${path}: code=${data.code} ${data.message || ''}`, { path, code: data.code, message: data.message });
407
+ }
408
+ return data;
409
+ }
410
+
411
+ // ─── Query Helpers ───────────────────────────────────────────────────────────
412
+
413
+ /**
414
+ * Check wallet balance.
415
+ * Returns { udvpn: number, dvpn: number }
416
+ */
417
+ export async function getBalance(client, address) {
418
+ const bal = await client.getBalance(address, 'udvpn');
419
+ const amount = parseInt(bal?.amount || '0', 10) || 0;
420
+ return { udvpn: amount, dvpn: amount / 1_000_000 };
421
+ }
422
+
423
+ /**
424
+ * Find an existing active session for a wallet+node pair.
425
+ * Returns session ID (BigInt) or null. Use this to avoid double-paying.
426
+ *
427
+ * Note: Sessions have a nested base_session object containing the actual data.
428
+ */
429
+ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
430
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`, 'sessions');
431
+ for (const s of items) {
432
+ const bs = s.base_session || s;
433
+ if ((bs.node_address || bs.node) !== nodeAddr) continue;
434
+ if (bs.status && bs.status !== 'active') continue;
435
+ const acct = bs.acc_address || bs.address;
436
+ if (acct && acct !== walletAddr) continue;
437
+ const maxBytes = parseInt(bs.max_bytes || '0');
438
+ const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
439
+ if (maxBytes === 0 || used < maxBytes) return BigInt(bs.id);
440
+ }
441
+ return null;
442
+ }
443
+
444
+ /**
445
+ * Resolve LCD node object to an HTTPS URL.
446
+ * LCD v3 returns `remote_addrs: ["IP:PORT"]` (array, NO protocol prefix).
447
+ * Legacy responses may have `remote_url: "https://IP:PORT"` (string with prefix).
448
+ * This handles both formats.
449
+ */
450
+ export function resolveNodeUrl(node) {
451
+ // Try legacy field first (string with https://)
452
+ if (node.remote_url && typeof node.remote_url === 'string') return node.remote_url;
453
+ // v3 LCD: remote_addrs is an array of "IP:PORT" strings
454
+ const addrs = node.remote_addrs || [];
455
+ const raw = addrs.find(a => a.includes(':')) || addrs[0];
456
+ if (!raw) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${node.address} has no remote_addrs`, { address: node.address });
457
+ return raw.startsWith('http') ? raw : `https://${raw}`;
458
+ }
459
+
460
+ /**
461
+ * Fetch all active nodes from LCD with pagination.
462
+ * Returns array of node objects. Each node has `remote_url` resolved from `remote_addrs`.
463
+ */
464
+ export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
465
+ const { items } = await lcdPaginatedSafe(lcdUrl, '/sentinel/node/v3/nodes?status=1', 'nodes', { limit });
466
+ for (const n of items) {
467
+ try { n.remote_url = resolveNodeUrl(n); } catch { /* skip nodes with no address */ }
468
+ }
469
+ return items;
470
+ }
471
+
472
+ /**
473
+ * Get a quick network overview — total nodes, counts by country and service type, average prices.
474
+ * Perfect for dashboard UIs, onboarding screens, and network health displays.
475
+ *
476
+ * @param {string} [lcdUrl] - LCD endpoint (default: cascading fallback)
477
+ * @returns {Promise<{ totalNodes: number, byCountry: Array<{country: string, count: number}>, byType: {wireguard: number, v2ray: number, unknown: number}, averagePrice: {gigabyteDvpn: number, hourlyDvpn: number}, nodes: Array }>}
478
+ *
479
+ * @example
480
+ * const overview = await getNetworkOverview();
481
+ * console.log(`${overview.totalNodes} nodes across ${overview.byCountry.length} countries`);
482
+ * console.log(`Average: ${overview.averagePrice.gigabyteDvpn.toFixed(3)} P2P/GB`);
483
+ */
484
+ export async function getNetworkOverview(lcdUrl) {
485
+ let nodes;
486
+ if (lcdUrl) {
487
+ nodes = await fetchActiveNodes(lcdUrl);
488
+ } else {
489
+ const result = await tryWithFallback(LCD_ENDPOINTS, fetchActiveNodes, 'getNetworkOverview');
490
+ nodes = result.result;
491
+ }
492
+
493
+ // Filter to nodes that accept udvpn
494
+ const active = nodes.filter(n => n.remote_url && (n.gigabyte_prices || []).some(p => p.denom === 'udvpn'));
495
+
496
+ // Count by country (from LCD metadata, limited — enrichNodes gives better data)
497
+ const countryMap = {};
498
+ for (const n of active) {
499
+ const c = n.location?.country || n.country || 'Unknown';
500
+ countryMap[c] = (countryMap[c] || 0) + 1;
501
+ }
502
+ const byCountry = Object.entries(countryMap)
503
+ .map(([country, count]) => ({ country, count }))
504
+ .sort((a, b) => b.count - a.count);
505
+
506
+ // Count by type (type not in LCD — estimate from service_type field if present)
507
+ const byType = { wireguard: 0, v2ray: 0, unknown: 0 };
508
+ for (const n of active) {
509
+ const t = n.service_type || n.type;
510
+ if (t === 'wireguard' || t === 1) byType.wireguard++;
511
+ else if (t === 'v2ray' || t === 2) byType.v2ray++;
512
+ else byType.unknown++;
513
+ }
514
+
515
+ // Average prices
516
+ let gbTotal = 0, gbCount = 0, hrTotal = 0, hrCount = 0;
517
+ for (const n of active) {
518
+ const gb = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
519
+ if (gb?.quote_value) { gbTotal += parseInt(gb.quote_value, 10); gbCount++; }
520
+ const hr = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
521
+ if (hr?.quote_value) { hrTotal += parseInt(hr.quote_value, 10); hrCount++; }
522
+ }
523
+
524
+ return {
525
+ totalNodes: active.length,
526
+ byCountry,
527
+ byType,
528
+ averagePrice: {
529
+ gigabyteDvpn: gbCount > 0 ? (gbTotal / gbCount) / 1_000_000 : 0,
530
+ hourlyDvpn: hrCount > 0 ? (hrTotal / hrCount) / 1_000_000 : 0,
531
+ },
532
+ nodes: active,
533
+ };
534
+ }
535
+
536
+ /**
537
+ * Discover plan IDs by probing subscription endpoints.
538
+ * Workaround for /sentinel/plan/v3/plans returning 501 Not Implemented.
539
+ * Returns sorted array of plan IDs that have at least 1 subscription.
540
+ */
541
+ export async function discoverPlanIds(lcdUrl, maxId = 500) {
542
+ // Delegates to discoverPlans and extracts just the IDs
543
+ const plans = await discoverPlans(lcdUrl, { maxId });
544
+ return plans.map(p => p.id);
545
+ }
546
+
547
+ /**
548
+ * Get standardized prices for a node — abstracts V3 LCD price parsing entirely.
549
+ *
550
+ * Solves the common "NaN / GB" problem by defensively extracting quote_value,
551
+ * base_value, or amount from the nested LCD response structure.
552
+ *
553
+ * @param {string} nodeAddress - sentnode1... address
554
+ * @param {string} [lcdUrl] - LCD endpoint URL (default: cascading fallback across all endpoints)
555
+ * @returns {Promise<{ gigabyte: { dvpn: number, udvpn: number, raw: object|null }, hourly: { dvpn: number, udvpn: number, raw: object|null }, denom: string, nodeAddress: string }>}
556
+ *
557
+ * @example
558
+ * const prices = await getNodePrices('sentnode1abc...');
559
+ * console.log(`${prices.gigabyte.dvpn} P2P/GB, ${prices.hourly.dvpn} P2P/hr`);
560
+ * // Use prices.gigabyte.raw for the full { denom, base_value, quote_value } object
561
+ * // needed by encodeMsgStartSession's max_price field.
562
+ */
563
+ export async function getNodePrices(nodeAddress, lcdUrl) {
564
+ if (typeof nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(nodeAddress)) {
565
+ throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (46 characters)', { value: nodeAddress });
566
+ }
567
+
568
+ // Reuse queryNode() instead of duplicating pagination
569
+ const node = await queryNode(nodeAddress, { lcdUrl });
570
+
571
+ function extractPrice(priceArray) {
572
+ if (!Array.isArray(priceArray)) return { dvpn: 0, udvpn: 0, raw: null };
573
+ const entry = priceArray.find(p => p.denom === 'udvpn');
574
+ if (!entry) return { dvpn: 0, udvpn: 0, raw: null };
575
+ // Defensive fallback chain: quote_value (V3 current) → base_value → amount (legacy)
576
+ const rawVal = entry.quote_value || entry.base_value || entry.amount || '0';
577
+ const udvpn = parseInt(rawVal, 10) || 0;
578
+ return { dvpn: parseFloat((udvpn / 1_000_000).toFixed(6)), udvpn, raw: entry };
579
+ }
580
+
581
+ return {
582
+ gigabyte: extractPrice(node.gigabyte_prices),
583
+ hourly: extractPrice(node.hourly_prices),
584
+ denom: 'P2P',
585
+ nodeAddress,
586
+ };
587
+ }
588
+
589
+ // ─── Display & Serialization Helpers ────────────────────────────────────────
590
+
591
+ /**
592
+ * Format a micro-denom (udvpn) amount as a human-readable P2P string.
593
+ *
594
+ * @param {number|string} udvpn - Amount in micro-denom (1 P2P = 1,000,000 udvpn)
595
+ * @param {number} [decimals=2] - Decimal places to show
596
+ * @returns {string} e.g., "0.04 P2P", "47.69 P2P"
597
+ *
598
+ * @example
599
+ * formatDvpn(40152030); // "40.15 P2P"
600
+ * formatDvpn('1000000', 0); // "1 P2P"
601
+ * formatDvpn(500000, 4); // "0.5000 P2P"
602
+ */
603
+ export function formatDvpn(udvpn, decimals = 2) {
604
+ const val = Number(udvpn) / 1_000_000;
605
+ if (isNaN(val)) return '? P2P';
606
+ return `${val.toFixed(decimals)} P2P`;
607
+ }
608
+
609
+ /** Alias for formatDvpn — uses the current P2P token name. */
610
+ export const formatP2P = formatDvpn;
611
+
612
+ /**
613
+ * Filter a node list by country, service type, and/or max price.
614
+ * Works with results from listNodes(), enrichNodes(), or fetchAllNodes().
615
+ *
616
+ * @param {Array} nodes - Array of node objects
617
+ * @param {object} criteria
618
+ * @param {string} [criteria.country] - Country name (case-insensitive partial match)
619
+ * @param {string} [criteria.serviceType] - 'wireguard' or 'v2ray'
620
+ * @param {number} [criteria.maxPriceDvpn] - Maximum GB price in P2P (e.g., 0.1)
621
+ * @param {number} [criteria.minScore] - Minimum quality score (0-100)
622
+ * @returns {Array} Filtered nodes
623
+ *
624
+ * @example
625
+ * const cheap = filterNodes(nodes, { maxPriceDvpn: 0.05, serviceType: 'v2ray' });
626
+ * const german = filterNodes(nodes, { country: 'Germany' });
627
+ */
628
+ export function filterNodes(nodes, criteria = {}) {
629
+ if (!Array.isArray(nodes)) return [];
630
+ return nodes.filter(node => {
631
+ if (criteria.country) {
632
+ const c = (node.country || node.location?.country || '').toLowerCase();
633
+ if (!c.includes(criteria.country.toLowerCase())) return false;
634
+ }
635
+ if (criteria.serviceType) {
636
+ const t = node.serviceType || node.type || '';
637
+ if (t !== criteria.serviceType) return false;
638
+ }
639
+ if (criteria.maxPriceDvpn != null) {
640
+ const prices = node.gigabytePrices || node.gigabyte_prices || [];
641
+ const entry = prices.find(p => p.denom === 'udvpn');
642
+ if (entry) {
643
+ const dvpn = parseInt(entry.quote_value || entry.base_value || entry.amount || '0', 10) / 1_000_000;
644
+ if (dvpn > criteria.maxPriceDvpn) return false;
645
+ }
646
+ }
647
+ if (criteria.minScore != null && node.qualityScore != null) {
648
+ if (node.qualityScore < criteria.minScore) return false;
649
+ }
650
+ return true;
651
+ });
652
+ }
653
+
654
+ /**
655
+ * Serialize a ConnectResult for JSON APIs. Handles BigInt → string conversion.
656
+ * Without this, JSON.stringify(connectResult) throws "BigInt can't be serialized".
657
+ *
658
+ * @param {object} result - ConnectResult from connectDirect/connectAuto/connectViaPlan
659
+ * @returns {object} JSON-safe object with sessionId as string
660
+ *
661
+ * @example
662
+ * const conn = await connectDirect(opts);
663
+ * res.json(serializeResult(conn)); // Safe for Express response
664
+ */
665
+ export function serializeResult(result) {
666
+ if (!result || typeof result !== 'object') return result;
667
+ const out = {};
668
+ for (const [key, val] of Object.entries(result)) {
669
+ if (typeof val === 'bigint') out[key] = String(val);
670
+ else if (typeof val === 'function') continue; // skip cleanup()
671
+ else out[key] = val;
672
+ }
673
+ return out;
674
+ }
675
+
676
+ /**
677
+ * Get P2P price in USD from CoinGecko (cached for 5 minutes).
678
+ */
679
+ let _dvpnPrice = null;
680
+ let _dvpnPriceAt = 0;
681
+ export async function getDvpnPrice() {
682
+ if (_dvpnPrice && Date.now() - _dvpnPriceAt < 300_000) return _dvpnPrice;
683
+ try {
684
+ const res = await axios.get('https://api.coingecko.com/api/v3/simple/price?ids=sentinel&vs_currencies=usd', { timeout: 10000 });
685
+ _dvpnPrice = res.data?.sentinel?.usd || null;
686
+ _dvpnPriceAt = Date.now();
687
+ } catch { /* keep old value */ }
688
+ return _dvpnPrice;
689
+ }
690
+
691
+ /**
692
+ * BigInt-safe serialization of TX result (for logging/API responses).
693
+ */
694
+ export function txResponse(result) {
695
+ return {
696
+ ok: result.code === 0,
697
+ txHash: result.transactionHash,
698
+ gasUsed: Number(result.gasUsed),
699
+ gasWanted: Number(result.gasWanted),
700
+ code: result.code,
701
+ rawLog: result.rawLog,
702
+ events: result.events,
703
+ };
704
+ }
705
+
706
+ // Re-export for convenience
707
+ export { extractSessionId };
708
+
709
+ // ─── Protobuf Helpers for FeeGrant & Authz ──────────────────────────────────
710
+ // Uses the same manual protobuf encoding as Sentinel types — no codegen needed.
711
+
712
+ function encodeCoin(denom, amount) {
713
+ return Buffer.concat([protoString(1, denom), protoString(2, String(amount))]);
714
+ }
715
+
716
+ function encodeTimestamp(date) {
717
+ const ms = date.getTime();
718
+ if (Number.isNaN(ms)) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'encodeTimestamp(): invalid date', { date });
719
+ const seconds = BigInt(Math.floor(ms / 1000));
720
+ return Buffer.concat([protoInt64(1, seconds)]);
721
+ }
722
+
723
+ function encodeAny(typeUrl, valueBytes) {
724
+ return Buffer.concat([
725
+ protoString(1, typeUrl),
726
+ protoEmbedded(2, valueBytes),
727
+ ]);
728
+ }
729
+
730
+ function encodeBasicAllowance(spendLimit, expiration) {
731
+ const parts = [];
732
+ if (spendLimit != null && spendLimit !== false) {
733
+ const coins = Array.isArray(spendLimit) ? spendLimit : [{ denom: 'udvpn', amount: String(spendLimit) }];
734
+ for (const coin of coins) {
735
+ parts.push(protoEmbedded(1, encodeCoin(coin.denom || 'udvpn', coin.amount)));
736
+ }
737
+ }
738
+ if (expiration) {
739
+ parts.push(protoEmbedded(2, encodeTimestamp(expiration instanceof Date ? expiration : new Date(expiration))));
740
+ }
741
+ return Buffer.concat(parts);
742
+ }
743
+
744
+ function encodeAllowedMsgAllowance(innerTypeUrl, innerBytes, allowedMessages) {
745
+ const parts = [protoEmbedded(1, encodeAny(innerTypeUrl, innerBytes))];
746
+ for (const msg of allowedMessages) {
747
+ parts.push(protoString(2, msg));
748
+ }
749
+ return Buffer.concat(parts);
750
+ }
751
+
752
+ function encodeGenericAuthorization(msgTypeUrl) {
753
+ return protoString(1, msgTypeUrl);
754
+ }
755
+
756
+ function encodeGrant(authTypeUrl, authBytes, expiration) {
757
+ const parts = [protoEmbedded(1, encodeAny(authTypeUrl, authBytes))];
758
+ if (expiration) {
759
+ parts.push(protoEmbedded(2, encodeTimestamp(expiration instanceof Date ? expiration : new Date(expiration))));
760
+ }
761
+ return Buffer.concat(parts);
762
+ }
763
+
764
+ // ─── FeeGrant (cosmos.feegrant.v1beta1) ─────────────────────────────────────
765
+ // Gas-free UX: granter pays fees for grantee's transactions.
766
+ //
767
+ // Usage:
768
+ // const msg = buildFeeGrantMsg(serviceAddr, userAddr, { spendLimit: 5000000 });
769
+ // await broadcast(client, serviceAddr, [msg]);
770
+ // // Now userAddr can broadcast without P2P for gas
771
+ // await broadcastWithFeeGrant(client, userAddr, [connectMsg], serviceAddr);
772
+
773
+ /**
774
+ * Build a MsgGrantAllowance message.
775
+ * @param {string} granter - Address paying fees (sent1...)
776
+ * @param {string} grantee - Address receiving fee grant (sent1...)
777
+ * @param {object} opts
778
+ * @param {number|Array} opts.spendLimit - Max spend in udvpn (number) or [{denom, amount}]
779
+ * @param {Date|string} opts.expiration - Optional expiry date
780
+ * @param {string[]} opts.allowedMessages - Optional: restrict to specific msg types (uses AllowedMsgAllowance)
781
+ */
782
+ export function buildFeeGrantMsg(granter, grantee, opts = {}) {
783
+ const { spendLimit, expiration, allowedMessages } = opts;
784
+ const basicBytes = encodeBasicAllowance(spendLimit, expiration);
785
+
786
+ let allowanceTypeUrl, allowanceBytes;
787
+ if (allowedMessages?.length) {
788
+ allowanceTypeUrl = '/cosmos.feegrant.v1beta1.AllowedMsgAllowance';
789
+ allowanceBytes = encodeAllowedMsgAllowance(
790
+ '/cosmos.feegrant.v1beta1.BasicAllowance', basicBytes, allowedMessages
791
+ );
792
+ } else {
793
+ allowanceTypeUrl = '/cosmos.feegrant.v1beta1.BasicAllowance';
794
+ allowanceBytes = basicBytes;
795
+ }
796
+
797
+ // MsgGrantAllowance: field 1=granter, field 2=grantee, field 3=allowance(Any)
798
+ return {
799
+ typeUrl: '/cosmos.feegrant.v1beta1.MsgGrantAllowance',
800
+ value: { granter, grantee, allowance: { typeUrl: allowanceTypeUrl, value: Uint8Array.from(allowanceBytes) } },
801
+ };
802
+ }
803
+
804
+ /**
805
+ * Build a MsgRevokeAllowance message.
806
+ */
807
+ export function buildRevokeFeeGrantMsg(granter, grantee) {
808
+ return {
809
+ typeUrl: '/cosmos.feegrant.v1beta1.MsgRevokeAllowance',
810
+ value: { granter, grantee },
811
+ };
812
+ }
813
+
814
+ /**
815
+ * Query fee grants given to a grantee.
816
+ * @returns {Promise<Array>} Array of allowance objects
817
+ */
818
+ export async function queryFeeGrants(lcdUrl, grantee) {
819
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`, 'allowances');
820
+ return items;
821
+ }
822
+
823
+ /**
824
+ * Query fee grants issued BY an address (where addr is the granter).
825
+ * @param {string} lcdUrl
826
+ * @param {string} granter - Address that issued the grants
827
+ * @returns {Promise<Array>}
828
+ */
829
+ export async function queryFeeGrantsIssued(lcdUrl, granter) {
830
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/issued/${granter}`, 'allowances');
831
+ return items;
832
+ }
833
+
834
+ /**
835
+ * Query a specific fee grant between granter and grantee.
836
+ * @returns {Promise<object|null>} Allowance object or null
837
+ */
838
+ export async function queryFeeGrant(lcdUrl, granter, grantee) {
839
+ try {
840
+ const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
841
+ return data.allowance || null;
842
+ } catch { return null; } // 404 = no grant
843
+ }
844
+
845
+ /**
846
+ * Broadcast a TX with fee paid by a granter (fee grant).
847
+ * The grantee signs; the granter pays gas via their fee allowance.
848
+ * @param {SigningStargateClient} client - Client with grantee's wallet
849
+ * @param {string} signerAddress - Grantee address (sent1...)
850
+ * @param {Array} msgs - Messages to broadcast
851
+ * @param {string} granterAddress - Fee granter address (sent1...)
852
+ * @param {string} memo - Optional memo
853
+ */
854
+ export async function broadcastWithFeeGrant(client, signerAddress, msgs, granterAddress, memo = '') {
855
+ // NOTE: client.simulate() does NOT support fee granter — it simulates without
856
+ // the granter field, causing "insufficient funds" if the grantee has low balance.
857
+ // Use fixed gas estimate instead. 300k gas covers all single-message Sentinel TXs.
858
+ // For multi-message batches, scale by message count.
859
+ const gasPerMsg = 200_000;
860
+ const gasLimit = Math.max(300_000, msgs.length * gasPerMsg);
861
+ const fee = {
862
+ amount: [{ denom: 'udvpn', amount: String(Math.ceil(gasLimit * 0.2)) }],
863
+ gas: String(gasLimit),
864
+ granter: granterAddress,
865
+ };
866
+ return client.signAndBroadcast(signerAddress, msgs, fee, memo);
867
+ }
868
+
869
+ // ─── Authz (cosmos.authz.v1beta1) ──────────────────────────────────────────
870
+ // Authorization grants: granter allows grantee to execute specific messages.
871
+ //
872
+ // Usage (server-side subscription management):
873
+ // // User grants server permission to start sessions on their behalf
874
+ // const msg = buildAuthzGrantMsg(userAddr, serverAddr, MSG_TYPES.PLAN_START_SESSION);
875
+ // await broadcast(client, userAddr, [msg]);
876
+ // // Server can now start sessions for the user
877
+ // const innerMsg = { typeUrl: MSG_TYPES.PLAN_START_SESSION, value: { from: userAddr, ... } };
878
+ // const execMsg = buildAuthzExecMsg(serverAddr, encodeForExec([innerMsg]));
879
+ // await broadcast(serverClient, serverAddr, [execMsg]);
880
+
881
+ /**
882
+ * Build a MsgGrant (authz) for a specific message type.
883
+ * @param {string} granter - Address granting permission (sent1...)
884
+ * @param {string} grantee - Address receiving permission (sent1...)
885
+ * @param {string} msgTypeUrl - Message type URL to authorize (e.g. MSG_TYPES.START_SESSION)
886
+ * @param {Date|string} expiration - Optional expiry date (default: no expiry)
887
+ */
888
+ export function buildAuthzGrantMsg(granter, grantee, msgTypeUrl, expiration) {
889
+ const authBytes = encodeGenericAuthorization(msgTypeUrl);
890
+
891
+ return {
892
+ typeUrl: '/cosmos.authz.v1beta1.MsgGrant',
893
+ value: {
894
+ granter,
895
+ grantee,
896
+ grant: {
897
+ authorization: {
898
+ typeUrl: '/cosmos.authz.v1beta1.GenericAuthorization',
899
+ value: Uint8Array.from(authBytes),
900
+ },
901
+ expiration: expiration
902
+ ? { seconds: BigInt(Math.floor((expiration instanceof Date ? expiration : new Date(expiration)).getTime() / 1000)), nanos: 0 }
903
+ : undefined,
904
+ },
905
+ },
906
+ };
907
+ }
908
+
909
+ /**
910
+ * Build a MsgRevoke (authz) to remove a specific grant.
911
+ */
912
+ export function buildAuthzRevokeMsg(granter, grantee, msgTypeUrl) {
913
+ return {
914
+ typeUrl: '/cosmos.authz.v1beta1.MsgRevoke',
915
+ value: { granter, grantee, msgTypeUrl },
916
+ };
917
+ }
918
+
919
+ /**
920
+ * Build a MsgExec (authz) to execute messages on behalf of a granter.
921
+ * @param {string} grantee - Address executing on behalf of granter
922
+ * @param {Array} encodedMsgs - Pre-encoded messages (use encodeForExec() to prepare)
923
+ */
924
+ export function buildAuthzExecMsg(grantee, encodedMsgs) {
925
+ return {
926
+ typeUrl: '/cosmos.authz.v1beta1.MsgExec',
927
+ value: { grantee, msgs: encodedMsgs },
928
+ };
929
+ }
930
+
931
+ /**
932
+ * Encode SDK message objects to the Any format required by MsgExec.
933
+ * @param {Array<{typeUrl: string, value: object}>} msgs - Standard SDK messages
934
+ * @returns {Array<{typeUrl: string, value: Uint8Array}>} Encoded messages for MsgExec
935
+ */
936
+ export function encodeForExec(msgs) {
937
+ const reg = buildRegistry();
938
+ return msgs.map(msg => {
939
+ const type = reg.lookupType(msg.typeUrl);
940
+ if (!type) throw new ChainError(ErrorCodes.UNKNOWN_MSG_TYPE, `Unknown message type: ${msg.typeUrl}. Ensure it is registered in buildRegistry().`, { typeUrl: msg.typeUrl });
941
+ return {
942
+ typeUrl: msg.typeUrl,
943
+ value: type.encode(type.fromPartial(msg.value)).finish(),
944
+ };
945
+ });
946
+ }
947
+
948
+ /**
949
+ * Query authz grants between granter and grantee.
950
+ * @returns {Promise<Array>} Array of grant objects
951
+ */
952
+ export async function queryAuthzGrants(lcdUrl, granter, grantee) {
953
+ const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`, 'grants');
954
+ return items;
955
+ }
956
+
957
+ // ─── LCD Query Helpers (v25b) ────────────────────────────────────────────────
958
+ // General-purpose LCD query with timeout, retry, error wrapping, and pagination.
959
+
960
+ /**
961
+ * Single LCD query with timeout, single retry on network error, and ChainError wrapping.
962
+ * Uses the fallback endpoint list if no lcdUrl is provided.
963
+ *
964
+ * @param {string} path - LCD path (e.g. '/sentinel/node/v3/nodes?status=1')
965
+ * @param {object} [opts]
966
+ * @param {string} [opts.lcdUrl] - Specific LCD endpoint (or uses fallback chain)
967
+ * @param {number} [opts.timeout] - Request timeout in ms (default: 15000)
968
+ * @returns {Promise<any>} Parsed JSON response
969
+ */
970
+ export async function lcdQuery(path, opts = {}) {
971
+ const timeout = opts.timeout || 15000;
972
+ const doQuery = async (baseUrl) => {
973
+ try {
974
+ return await lcd(baseUrl, path);
975
+ } catch (err) {
976
+ // Single retry on network error
977
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.message?.includes('timeout')) {
978
+ await new Promise(r => setTimeout(r, 1000));
979
+ return await lcd(baseUrl, path);
980
+ }
981
+ throw err;
982
+ }
983
+ };
984
+
985
+ if (opts.lcdUrl) {
986
+ return doQuery(opts.lcdUrl);
987
+ }
988
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD ${path}`);
989
+ return result;
990
+ }
991
+
992
+ /**
993
+ * Auto-paginating LCD query. Fetches all pages via next_key, returns all results + chain total.
994
+ *
995
+ * @param {string} basePath - LCD path without pagination params (e.g. '/sentinel/node/v3/nodes?status=1')
996
+ * @param {object} [opts]
997
+ * @param {string} [opts.lcdUrl] - Specific LCD endpoint (or uses fallback chain)
998
+ * @param {number} [opts.limit] - Page size (default: 200)
999
+ * @param {number} [opts.timeout] - Per-page timeout (default: 15000)
1000
+ * @param {string} [opts.dataKey] - Key for the results array in response (default: auto-detect first array)
1001
+ * @returns {Promise<{ items: any[], total: number|null }>}
1002
+ */
1003
+ export async function lcdQueryAll(basePath, opts = {}) {
1004
+ const limit = opts.limit || 200;
1005
+ const dataKey = opts.dataKey || null;
1006
+
1007
+ const fetchAll = async (baseUrl) => {
1008
+ let allItems = [];
1009
+ let nextKey = null;
1010
+ let chainTotal = null;
1011
+ let isFirst = true;
1012
+ do {
1013
+ const sep = basePath.includes('?') ? '&' : '?';
1014
+ let url = `${basePath}${sep}pagination.limit=${limit}`;
1015
+ if (isFirst) url += '&pagination.count_total=true';
1016
+ if (nextKey) url += `&pagination.key=${encodeURIComponent(nextKey)}`;
1017
+ const data = await lcd(baseUrl, url);
1018
+ if (isFirst && data.pagination?.total) {
1019
+ chainTotal = parseInt(data.pagination.total, 10);
1020
+ }
1021
+ // Auto-detect data key: first array property that isn't 'pagination'
1022
+ const key = dataKey || Object.keys(data).find(k => k !== 'pagination' && Array.isArray(data[k]));
1023
+ const pageItems = key ? (data[key] || []) : [];
1024
+ allItems = allItems.concat(pageItems);
1025
+ nextKey = data.pagination?.next_key || null;
1026
+ isFirst = false;
1027
+ } while (nextKey);
1028
+
1029
+ if (chainTotal && allItems.length !== chainTotal) {
1030
+ console.warn(`[lcdQueryAll] Pagination mismatch: got ${allItems.length}, chain reports ${chainTotal}`);
1031
+ }
1032
+ return { items: allItems, total: chainTotal };
1033
+ };
1034
+
1035
+ if (opts.lcdUrl) {
1036
+ return fetchAll(opts.lcdUrl);
1037
+ }
1038
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchAll, `LCD paginated ${basePath}`);
1039
+ return result;
1040
+ }
1041
+
1042
+ // ─── Plan Subscriber Helpers (v25b) ──────────────────────────────────────────
1043
+
1044
+ /**
1045
+ * Query all subscriptions for a plan. Supports owner filtering.
1046
+ *
1047
+ * @param {number|string} planId - Plan ID
1048
+ * @param {object} [opts]
1049
+ * @param {string} [opts.lcdUrl] - LCD endpoint
1050
+ * @param {string} [opts.excludeAddress] - Filter out this address (typically the plan owner)
1051
+ * @returns {Promise<{ subscribers: Array<{ address: string, status: number, id: string }>, total: number|null }>}
1052
+ */
1053
+ export async function queryPlanSubscribers(planId, opts = {}) {
1054
+ const { items, total } = await lcdQueryAll(
1055
+ `/sentinel/subscription/v3/plans/${planId}/subscriptions`,
1056
+ { lcdUrl: opts.lcdUrl, dataKey: 'subscriptions' },
1057
+ );
1058
+ let subscribers = items.map(s => ({
1059
+ address: s.address || s.subscriber,
1060
+ status: s.status,
1061
+ id: s.id || s.base_id,
1062
+ ...s,
1063
+ }));
1064
+ if (opts.excludeAddress) {
1065
+ subscribers = subscribers.filter(s => s.address !== opts.excludeAddress);
1066
+ }
1067
+ return { subscribers, total };
1068
+ }
1069
+
1070
+ /**
1071
+ * Get plan stats with self-subscription filtered out.
1072
+ *
1073
+ * @param {number|string} planId
1074
+ * @param {string} ownerAddress - Plan owner's sent1... address (filtered from counts)
1075
+ * @param {object} [opts]
1076
+ * @param {string} [opts.lcdUrl]
1077
+ * @returns {Promise<{ subscriberCount: number, totalOnChain: number, ownerSubscribed: boolean }>}
1078
+ */
1079
+ export async function getPlanStats(planId, ownerAddress, opts = {}) {
1080
+ const { subscribers, total } = await queryPlanSubscribers(planId, { lcdUrl: opts.lcdUrl });
1081
+ const ownerSubscribed = subscribers.some(s => s.address === ownerAddress);
1082
+ const filtered = subscribers.filter(s => s.address !== ownerAddress);
1083
+ return {
1084
+ subscriberCount: filtered.length,
1085
+ totalOnChain: total,
1086
+ ownerSubscribed,
1087
+ };
1088
+ }
1089
+
1090
+ // ─── Fee Grant Workflow Helpers (v25b) ────────────────────────────────────────
1091
+
1092
+ /**
1093
+ * Grant fee allowance to all plan subscribers who don't already have one.
1094
+ * Filters out self-grants (granter === grantee) and already-granted addresses.
1095
+ *
1096
+ * @param {number|string} planId
1097
+ * @param {object} opts
1098
+ * @param {string} opts.granterAddress - Who pays fees (typically plan owner)
1099
+ * @param {string} opts.lcdUrl - LCD endpoint
1100
+ * @param {object} [opts.grantOpts] - Options for buildFeeGrantMsg (spendLimit, expiration, allowedMessages)
1101
+ * @returns {Promise<{ msgs: Array, skipped: string[], newGrants: string[] }>} Messages ready for broadcast
1102
+ */
1103
+ export async function grantPlanSubscribers(planId, opts = {}) {
1104
+ const { granterAddress, lcdUrl, grantOpts = {} } = opts;
1105
+ if (!granterAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granterAddress is required');
1106
+
1107
+ // Get subscribers
1108
+ const { subscribers } = await queryPlanSubscribers(planId, { lcdUrl });
1109
+
1110
+ // Get existing grants ISSUED BY granter (not grants received)
1111
+ const existingGrants = await queryFeeGrantsIssued(lcdUrl || LCD_ENDPOINTS[0].url, granterAddress);
1112
+ const alreadyGranted = new Set(existingGrants.map(g => g.grantee));
1113
+
1114
+ const msgs = [];
1115
+ const skipped = [];
1116
+ const newGrants = [];
1117
+
1118
+ const now = new Date();
1119
+ // Deduplicate by address and filter active+non-expired
1120
+ const seen = new Set();
1121
+ for (const sub of subscribers) {
1122
+ const addr = sub.acc_address || sub.address;
1123
+ if (!addr || seen.has(addr)) continue;
1124
+ seen.add(addr);
1125
+ // Skip self-grant (chain rejects granter === grantee)
1126
+ if (addr === granterAddress || isSameKey(addr, granterAddress)) { skipped.push(addr); continue; }
1127
+ // Skip inactive or expired
1128
+ if (sub.status && sub.status !== 'active') { skipped.push(addr); continue; }
1129
+ if (sub.inactive_at && new Date(sub.inactive_at) <= now) { skipped.push(addr); continue; }
1130
+ // Skip already granted
1131
+ if (alreadyGranted.has(addr)) { skipped.push(addr); continue; }
1132
+ msgs.push(buildFeeGrantMsg(granterAddress, addr, grantOpts));
1133
+ newGrants.push(addr);
1134
+ }
1135
+
1136
+ return { msgs, skipped, newGrants };
1137
+ }
1138
+
1139
+ /**
1140
+ * Find fee grants expiring within N days.
1141
+ *
1142
+ * @param {string} lcdUrl - LCD endpoint
1143
+ * @param {string} granteeOrGranter - Address to check grants for
1144
+ * @param {number} withinDays - Check grants expiring within this many days (default: 7)
1145
+ * @param {'grantee'|'granter'} [role='grantee'] - Whether to check as grantee or granter
1146
+ * @returns {Promise<Array<{ granter: string, grantee: string, expiresAt: Date|null, daysLeft: number|null }>>}
1147
+ */
1148
+ export async function getExpiringGrants(lcdUrl, granteeOrGranter, withinDays = 7, role = 'grantee') {
1149
+ const grants = role === 'grantee'
1150
+ ? await queryFeeGrants(lcdUrl, granteeOrGranter)
1151
+ : await queryFeeGrantsIssued(lcdUrl, granteeOrGranter);
1152
+
1153
+ const now = Date.now();
1154
+ const cutoff = now + withinDays * 24 * 60 * 60_000;
1155
+ const expiring = [];
1156
+
1157
+ for (const g of grants) {
1158
+ // Fee grant allowances have complex nested @type structures:
1159
+ // BasicAllowance: { expiration }
1160
+ // PeriodicAllowance: { basic: { expiration } }
1161
+ // AllowedMsgAllowance: { allowance: { expiration } or allowance: { basic: { expiration } } }
1162
+ const a = g.allowance || {};
1163
+ const inner = a.allowance || a; // unwrap AllowedMsgAllowance
1164
+ const expStr = inner.expiration || inner.basic?.expiration || a.expiration || a.basic?.expiration;
1165
+ if (!expStr) continue; // no expiry set
1166
+ const expiresAt = new Date(expStr);
1167
+ if (expiresAt.getTime() <= cutoff) {
1168
+ expiring.push({
1169
+ granter: g.granter,
1170
+ grantee: g.grantee,
1171
+ expiresAt,
1172
+ daysLeft: Math.max(0, Math.round((expiresAt.getTime() - now) / (24 * 60 * 60_000))),
1173
+ });
1174
+ }
1175
+ }
1176
+ return expiring;
1177
+ }
1178
+
1179
+ /**
1180
+ * Revoke and re-grant expiring fee grants.
1181
+ *
1182
+ * @param {string} lcdUrl
1183
+ * @param {string} granterAddress
1184
+ * @param {number} withinDays - Renew grants expiring within N days
1185
+ * @param {object} [grantOpts] - Options for new grants (spendLimit, expiration, allowedMessages)
1186
+ * @returns {Promise<{ msgs: Array, renewed: string[] }>} Messages ready for broadcast
1187
+ */
1188
+ export async function renewExpiringGrants(lcdUrl, granterAddress, withinDays = 7, grantOpts = {}) {
1189
+ const expiring = await getExpiringGrants(lcdUrl, granterAddress, withinDays, 'granter');
1190
+ const msgs = [];
1191
+ const renewed = [];
1192
+
1193
+ for (const g of expiring) {
1194
+ if (g.grantee === granterAddress) continue; // skip self
1195
+ msgs.push(buildRevokeFeeGrantMsg(granterAddress, g.grantee));
1196
+ msgs.push(buildFeeGrantMsg(granterAddress, g.grantee, grantOpts));
1197
+ renewed.push(g.grantee);
1198
+ }
1199
+
1200
+ return { msgs, renewed };
1201
+ }
1202
+
1203
+ // ─── Fee Grant Monitoring (v25b) ─────────────────────────────────────────────
1204
+
1205
+ /**
1206
+ * Monitor fee grants for expiry. Returns an EventEmitter that checks grants on interval.
1207
+ *
1208
+ * @param {object} opts
1209
+ * @param {string} opts.lcdUrl - LCD endpoint
1210
+ * @param {string} opts.address - Address to monitor (as granter)
1211
+ * @param {number} [opts.checkIntervalMs] - Check interval (default: 6 hours)
1212
+ * @param {number} [opts.warnDays] - Emit 'expiring' when grant expires within N days (default: 7)
1213
+ * @param {boolean} [opts.autoRenew] - Auto-revoke+re-grant expiring grants (default: false)
1214
+ * @param {object} [opts.grantOpts] - Options for renewed grants
1215
+ * @returns {EventEmitter} Emits 'expiring' and 'expired' events. Call .stop() to stop monitoring.
1216
+ */
1217
+ export function monitorFeeGrants(opts = {}) {
1218
+ const { lcdUrl, address, checkIntervalMs = 6 * 60 * 60_000, warnDays = 7, autoRenew = false, grantOpts = {} } = opts;
1219
+ if (!lcdUrl || !address) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'monitorFeeGrants requires lcdUrl and address');
1220
+
1221
+ const emitter = new EventEmitter();
1222
+ let timer = null;
1223
+
1224
+ const check = async () => {
1225
+ try {
1226
+ const expiring = await getExpiringGrants(lcdUrl, address, warnDays, 'granter');
1227
+ const now = Date.now();
1228
+
1229
+ for (const g of expiring) {
1230
+ if (g.expiresAt.getTime() <= now) {
1231
+ emitter.emit('expired', g);
1232
+ } else {
1233
+ emitter.emit('expiring', g);
1234
+ }
1235
+ }
1236
+
1237
+ if (autoRenew && expiring.length > 0) {
1238
+ const { msgs, renewed } = await renewExpiringGrants(lcdUrl, address, warnDays, grantOpts);
1239
+ if (msgs.length > 0) {
1240
+ emitter.emit('renew', { msgs, renewed });
1241
+ }
1242
+ }
1243
+ } catch (err) {
1244
+ emitter.emit('error', err);
1245
+ }
1246
+ };
1247
+
1248
+ // Start checking
1249
+ check();
1250
+ timer = setInterval(check, checkIntervalMs);
1251
+ if (timer.unref) timer.unref(); // Don't prevent process exit
1252
+
1253
+ emitter.stop = () => {
1254
+ if (timer) { clearInterval(timer); timer = null; }
1255
+ };
1256
+
1257
+ return emitter;
1258
+ }
1259
+
1260
+ // ─── Query Helpers (v25c) ────────────────────────────────────────────────────
1261
+
1262
+ /**
1263
+ * Query a wallet's active subscriptions.
1264
+ * @param {string} lcdUrl
1265
+ * @param {string} walletAddr - sent1... address
1266
+ * @returns {Promise<{ subscriptions: any[], total: number|null }>}
1267
+ */
1268
+ export async function querySubscriptions(lcdUrl, walletAddr, opts = {}) {
1269
+ // v26: Correct LCD endpoint for wallet subscriptions
1270
+ let path = `/sentinel/subscription/v3/accounts/${walletAddr}/subscriptions`;
1271
+ if (opts.status) path += `?status=${opts.status === 'active' ? '1' : '2'}`;
1272
+ return lcdQueryAll(path, { lcdUrl, dataKey: 'subscriptions' });
1273
+ }
1274
+
1275
+ /**
1276
+ * Query session allocation (remaining bandwidth).
1277
+ * @param {string} lcdUrl
1278
+ * @param {string|number|bigint} sessionId
1279
+ * @returns {Promise<{ maxBytes: number, usedBytes: number, remainingBytes: number, percentUsed: number }|null>}
1280
+ */
1281
+ export async function querySessionAllocation(lcdUrl, sessionId) {
1282
+ try {
1283
+ const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions/${sessionId}`);
1284
+ const s = data.session?.base_session || data.session || {};
1285
+ const maxBytes = parseInt(s.max_bytes || '0', 10);
1286
+ const dl = parseInt(s.download_bytes || '0', 10);
1287
+ const ul = parseInt(s.upload_bytes || '0', 10);
1288
+ const usedBytes = dl + ul;
1289
+ return {
1290
+ maxBytes,
1291
+ usedBytes,
1292
+ remainingBytes: Math.max(0, maxBytes - usedBytes),
1293
+ percentUsed: maxBytes > 0 ? Math.round((usedBytes / maxBytes) * 100) : 0,
1294
+ };
1295
+ } catch { return null; }
1296
+ }
1297
+
1298
+ /**
1299
+ * Fetch a single node by address from LCD (no need to fetch all 1000+ nodes).
1300
+ * Tries the direct v3 endpoint first, falls back to paginated search.
1301
+ *
1302
+ * @param {string} nodeAddress - sentnode1... address
1303
+ * @param {object} [opts]
1304
+ * @param {string} [opts.lcdUrl] - LCD endpoint (or uses fallback chain)
1305
+ * @returns {Promise<object>} Node object with remote_url resolved
1306
+ */
1307
+ export async function queryNode(nodeAddress, opts = {}) {
1308
+ if (typeof nodeAddress !== 'string' || !nodeAddress.startsWith('sentnode1')) {
1309
+ throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be sentnode1...', { nodeAddress });
1310
+ }
1311
+
1312
+ const fetchDirect = async (baseUrl) => {
1313
+ try {
1314
+ const data = await lcdQuery(`/sentinel/node/v3/nodes/${nodeAddress}`, { lcdUrl: baseUrl });
1315
+ if (data?.node) {
1316
+ data.node.remote_url = resolveNodeUrl(data.node);
1317
+ return data.node;
1318
+ }
1319
+ } catch { /* fall through to full list */ }
1320
+ const { items } = await lcdPaginatedSafe(baseUrl, '/sentinel/node/v3/nodes?status=1', 'nodes');
1321
+ const found = items.find(n => n.address === nodeAddress);
1322
+ if (!found) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found on LCD (may be inactive)`, { nodeAddress });
1323
+ found.remote_url = resolveNodeUrl(found);
1324
+ return found;
1325
+ };
1326
+
1327
+ if (opts.lcdUrl) return fetchDirect(opts.lcdUrl);
1328
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, fetchDirect, `LCD node lookup ${nodeAddress}`);
1329
+ return result;
1330
+ }
1331
+
1332
+ /**
1333
+ * Build batch MsgStartSession messages for multiple nodes in one TX.
1334
+ * Saves gas vs separate TXs (~800k gas for 5 sessions vs 200k × 5 = 1M).
1335
+ *
1336
+ * @param {string} from - Wallet address (sent1...)
1337
+ * @param {Array<{ nodeAddress: string, gigabytes?: number, maxPrice: { denom: string, base_value: string, quote_value: string } }>} nodes
1338
+ * @returns {Array<{ typeUrl: string, value: object }>} Messages ready for broadcast()
1339
+ */
1340
+ export function buildBatchStartSession(from, nodes) {
1341
+ return nodes.map(n => ({
1342
+ typeUrl: '/sentinel.node.v3.MsgStartSessionRequest',
1343
+ value: {
1344
+ from,
1345
+ node_address: n.nodeAddress,
1346
+ gigabytes: n.gigabytes || 1,
1347
+ hours: 0,
1348
+ max_price: n.maxPrice,
1349
+ },
1350
+ }));
1351
+ }
1352
+
1353
+ /**
1354
+ * Build MsgEndSession to close a session early (stop paying for bandwidth).
1355
+ * @param {string} from - Wallet address
1356
+ * @param {number|string|bigint} sessionId - Session to end
1357
+ * @returns {{ typeUrl: string, value: object }}
1358
+ */
1359
+ export function buildEndSessionMsg(from, sessionId) {
1360
+ return {
1361
+ typeUrl: '/sentinel.session.v3.MsgCancelSessionRequest',
1362
+ value: { from, id: BigInt(sessionId) },
1363
+ };
1364
+ }
1365
+
1366
+ // ─── v26c: Defensive Pagination ──────────────────────────────────────────────
1367
+
1368
+ /**
1369
+ * Paginated LCD query that handles Sentinel's broken pagination.
1370
+ * Tries next_key first. If next_key is null but we got exactly `limit` results
1371
+ * (suggesting truncation), falls back to a single large request.
1372
+ *
1373
+ * @param {string} lcdUrl - LCD base URL
1374
+ * @param {string} path - Endpoint path (e.g. '/sentinel/node/v3/plans/36/nodes')
1375
+ * @param {string} itemsKey - Response array key ('nodes', 'subscriptions', 'sessions')
1376
+ * @param {object} [opts]
1377
+ * @param {number} [opts.limit=500] - Page size for paginated requests
1378
+ * @param {number} [opts.fallbackLimit=5000] - Single-request limit if pagination broken
1379
+ * @returns {Promise<{ items: any[], total: number }>}
1380
+ */
1381
+ export async function lcdPaginatedSafe(lcdUrl, path, itemsKey, opts = {}) {
1382
+ const limit = opts.limit || 500;
1383
+ const fallbackLimit = opts.fallbackLimit || 5000;
1384
+ const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
1385
+ const sep = path.includes('?') ? '&' : '?';
1386
+
1387
+ const firstPage = await lcd(baseLcd, `${path}${sep}pagination.limit=${limit}`);
1388
+ const firstItems = firstPage[itemsKey] || [];
1389
+ const nextKey = firstPage.pagination?.next_key;
1390
+
1391
+ // Fewer than limit = that's everything
1392
+ if (firstItems.length < limit) {
1393
+ return { items: firstItems, total: firstItems.length };
1394
+ }
1395
+
1396
+ // next_key exists = pagination works, follow it
1397
+ if (nextKey) {
1398
+ let allItems = [...firstItems];
1399
+ let key = nextKey;
1400
+ while (key) {
1401
+ const page = await lcd(baseLcd, `${path}${sep}pagination.limit=${limit}&pagination.key=${encodeURIComponent(key)}`);
1402
+ allItems.push(...(page[itemsKey] || []));
1403
+ key = page.pagination?.next_key || null;
1404
+ }
1405
+ return { items: allItems, total: allItems.length };
1406
+ }
1407
+
1408
+ // next_key null but hit limit = broken pagination. Single large request.
1409
+ const fullData = await lcd(baseLcd, `${path}${sep}pagination.limit=${fallbackLimit}`);
1410
+ const allItems = fullData[itemsKey] || [];
1411
+ return { items: allItems, total: allItems.length };
1412
+ }
1413
+
1414
+ // ─── v26c: Session & Subscription Queries ────────────────────────────────────
1415
+
1416
+ /**
1417
+ * List all sessions for a wallet address.
1418
+ * @param {string} address - sent1... wallet address
1419
+ * @param {string} [lcdUrl]
1420
+ * @param {object} [opts]
1421
+ * @param {string} [opts.status] - '1' (active) or '2' (inactive)
1422
+ * @returns {Promise<{ items: ChainSession[], total: number }>}
1423
+ */
1424
+ export async function querySessions(address, lcdUrl, opts = {}) {
1425
+ let path = `/sentinel/session/v3/sessions?address=${address}`;
1426
+ if (opts.status) path += `&status=${opts.status}`;
1427
+ const result = await lcdPaginatedSafe(lcdUrl, path, 'sessions');
1428
+ // Auto-flatten base_session nesting so devs don't hit session.id === undefined
1429
+ result.items = result.items.map(flattenSession);
1430
+ return result;
1431
+ }
1432
+
1433
+ /**
1434
+ * Flatten a chain session's base_session fields to top level.
1435
+ * Prevents the #1 footgun: `session.id === undefined` (data is nested under base_session).
1436
+ * Preserves `price` (node sessions) and `subscription_id` (plan sessions).
1437
+ *
1438
+ * @param {object} session - Raw LCD session object
1439
+ * @returns {object} Flattened session with all fields at top level
1440
+ */
1441
+ export function flattenSession(session) {
1442
+ if (!session) return session;
1443
+ const bs = session.base_session || {};
1444
+ return {
1445
+ id: bs.id || session.id,
1446
+ acc_address: bs.acc_address || session.acc_address,
1447
+ node_address: bs.node_address || bs.node || session.node_address,
1448
+ download_bytes: bs.download_bytes || session.download_bytes || '0',
1449
+ upload_bytes: bs.upload_bytes || session.upload_bytes || '0',
1450
+ max_bytes: bs.max_bytes || session.max_bytes || '0',
1451
+ duration: bs.duration || session.duration,
1452
+ max_duration: bs.max_duration || session.max_duration,
1453
+ status: bs.status || session.status,
1454
+ start_at: bs.start_at || session.start_at,
1455
+ status_at: bs.status_at || session.status_at,
1456
+ inactive_at: bs.inactive_at || session.inactive_at,
1457
+ // Preserve type-specific fields
1458
+ price: session.price || undefined,
1459
+ subscription_id: session.subscription_id || undefined,
1460
+ '@type': session['@type'] || undefined,
1461
+ _raw: session, // original for advanced use
1462
+ };
1463
+ }
1464
+
1465
+ /**
1466
+ * Get a single subscription by ID.
1467
+ * @param {string|number} id - Subscription ID
1468
+ * @param {string} [lcdUrl]
1469
+ * @returns {Promise<Subscription|null>}
1470
+ */
1471
+ export async function querySubscription(id, lcdUrl) {
1472
+ try {
1473
+ const data = await lcdQuery(`/sentinel/subscription/v3/subscriptions/${id}`, { lcdUrl });
1474
+ return data.subscription || null;
1475
+ } catch { return null; }
1476
+ }
1477
+
1478
+ /**
1479
+ * Check if wallet has an active subscription for a specific plan.
1480
+ * @param {string} address - sent1... wallet address
1481
+ * @param {number|string} planId - Plan ID to check
1482
+ * @param {string} [lcdUrl]
1483
+ * @returns {Promise<{ has: boolean, subscription?: object }>}
1484
+ */
1485
+ export async function hasActiveSubscription(address, planId, lcdUrl) {
1486
+ const { items } = await querySubscriptions(lcdUrl, address, { status: 'active' });
1487
+ const match = items.find(s => String(s.plan_id) === String(planId));
1488
+ if (match) return { has: true, subscription: match };
1489
+ return { has: false };
1490
+ }
1491
+
1492
+ // ─── v26c: Display Helpers ───────────────────────────────────────────────────
1493
+
1494
+ /**
1495
+ * Format byte count for display.
1496
+ * @param {number} bytes
1497
+ * @returns {string} e.g. '1.5 GB', '340 MB', '1.2 KB'
1498
+ */
1499
+ export function formatBytes(bytes) {
1500
+ if (bytes == null || isNaN(bytes)) return '0 B';
1501
+ if (bytes < 1024) return bytes + ' B';
1502
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
1503
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
1504
+ return (bytes / 1073741824).toFixed(2) + ' GB';
1505
+ }
1506
+
1507
+ /**
1508
+ * Parse chain duration string (has "s" suffix).
1509
+ * @param {string} durationStr - e.g. '557817.727815887s'
1510
+ * @returns {{ seconds: number, hours: number, minutes: number, formatted: string }}
1511
+ */
1512
+ export function parseChainDuration(durationStr) {
1513
+ const seconds = parseFloat(String(durationStr).replace(/s$/i, '')) || 0;
1514
+ const hours = Math.floor(seconds / 3600);
1515
+ const minutes = Math.floor((seconds % 3600) / 60);
1516
+ const secs = Math.floor(seconds % 60);
1517
+ const formatted = hours > 0 ? `${hours}h ${minutes}m` : minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`;
1518
+ return { seconds, hours, minutes, formatted };
1519
+ }
1520
+
1521
+ // ─── v26: Field Experience Helpers ────────────────────────────────────────────
1522
+
1523
+ /**
1524
+ * Query all nodes linked to a plan.
1525
+ * @param {number|string} planId
1526
+ * @param {string} [lcdUrl]
1527
+ * @returns {Promise<{ items: any[], total: number|null }>}
1528
+ */
1529
+ export async function queryPlanNodes(planId, lcdUrl) {
1530
+ // LCD pagination is BROKEN on this endpoint — count_total returns min(actual, limit)
1531
+ // and next_key is always null. Single high-limit request gets all nodes.
1532
+ const doQuery = async (baseUrl) => {
1533
+ const data = await lcd(baseUrl, `/sentinel/node/v3/plans/${planId}/nodes?pagination.limit=5000`);
1534
+ return { items: data.nodes || [], total: (data.nodes || []).length };
1535
+ };
1536
+ if (lcdUrl) return doQuery(lcdUrl);
1537
+ const { result } = await tryWithFallback(LCD_ENDPOINTS, doQuery, `LCD plan ${planId} nodes`);
1538
+ return result;
1539
+ }
1540
+
1541
+ /**
1542
+ * Discover all available plans with metadata (subscriber count, node count, price).
1543
+ * Probes plan IDs 1-maxId, returns plans with >=1 subscriber.
1544
+ *
1545
+ * @param {string} [lcdUrl]
1546
+ * @param {object} [opts]
1547
+ * @param {number} [opts.maxId=500] - Highest plan ID to probe
1548
+ * @param {number} [opts.batchSize=15] - Parallel probes per batch
1549
+ * @param {boolean} [opts.includeEmpty=false] - Include plans with 0 nodes
1550
+ * @returns {Promise<Array<{ id: number, subscribers: number, nodeCount: number, price: object|null, hasNodes: boolean }>>}
1551
+ */
1552
+ export async function discoverPlans(lcdUrl, opts = {}) {
1553
+ const maxId = opts.maxId || 500;
1554
+ const batchSize = opts.batchSize || 15;
1555
+ const includeEmpty = opts.includeEmpty || false;
1556
+ const baseLcd = lcdUrl || LCD_ENDPOINTS[0].url;
1557
+ const plans = [];
1558
+
1559
+ for (let batch = 0; batch < Math.ceil(maxId / batchSize); batch++) {
1560
+ const probes = [];
1561
+ for (let i = batch * batchSize + 1; i <= Math.min((batch + 1) * batchSize, maxId); i++) {
1562
+ probes.push((async (id) => {
1563
+ try {
1564
+ const subData = await lcd(baseLcd, `/sentinel/subscription/v3/plans/${id}/subscriptions?pagination.limit=1&pagination.count_total=true`);
1565
+ const subCount = parseInt(subData.pagination?.total || '0', 10);
1566
+ if (subCount === 0 && !includeEmpty) return null;
1567
+ // Plan nodes endpoint has broken pagination (count_total wrong, next_key null).
1568
+ // Use limit=5000 single request and count the actual array.
1569
+ const nodeData = await lcd(baseLcd, `/sentinel/node/v3/plans/${id}/nodes?pagination.limit=5000`);
1570
+ const nodeCount = (nodeData.nodes || []).length;
1571
+ const price = subData.subscriptions?.[0]?.price || null;
1572
+ return { id, subscribers: subCount, nodeCount, price, hasNodes: nodeCount > 0 };
1573
+ } catch { return null; }
1574
+ })(i));
1575
+ }
1576
+ const results = await Promise.all(probes);
1577
+ for (const r of results) if (r) plans.push(r);
1578
+ }
1579
+ return plans.sort((a, b) => a.id - b.id);
1580
+ }
1581
+
1582
+ /**
1583
+ * Truncate an address for display. Works with sent1, sentprov1, sentnode1.
1584
+ * @param {string} addr
1585
+ * @param {number} [prefixLen=12]
1586
+ * @param {number} [suffixLen=6]
1587
+ * @returns {string}
1588
+ */
1589
+ export function shortAddress(addr, prefixLen = 12, suffixLen = 6) {
1590
+ if (!addr || addr.length <= prefixLen + suffixLen + 3) return addr || '';
1591
+ return `${addr.slice(0, prefixLen)}...${addr.slice(-suffixLen)}`;
1592
+ }
1593
+
1594
+ /**
1595
+ * Format subscription expiry as relative time.
1596
+ * @param {object} subscription - LCD subscription object (or any object with inactive_at)
1597
+ * @returns {string} e.g. "23d left", "4h left", "expired"
1598
+ */
1599
+ export function formatSubscriptionExpiry(subscription) {
1600
+ const iso = subscription?.inactive_at || subscription?.status_at;
1601
+ if (!iso) return 'unknown';
1602
+ const diff = new Date(iso).getTime() - Date.now();
1603
+ if (diff < 0) return 'expired';
1604
+ const days = Math.floor(diff / 86400000);
1605
+ if (days > 1) return `${days}d left`;
1606
+ const hrs = Math.floor(diff / 3600000);
1607
+ if (hrs > 0) return `${hrs}h left`;
1608
+ const mins = Math.floor(diff / 60000);
1609
+ if (mins > 0) return `${mins}m left`;
1610
+ return '<1m left';
1611
+ }
1612
+
1613
+ /**
1614
+ * Send P2P tokens to an address.
1615
+ * @param {SigningStargateClient} client
1616
+ * @param {string} fromAddress
1617
+ * @param {string} toAddress
1618
+ * @param {number|string} amountUdvpn - Amount in micro-denom
1619
+ * @param {string} [memo='']
1620
+ * @returns {Promise<DeliverTxResponse>}
1621
+ */
1622
+ export async function sendTokens(client, fromAddress, toAddress, amountUdvpn, memo = '') {
1623
+ // Robust amount extraction: handle string, number, bigint, or coin object { amount, denom }
1624
+ let amountStr;
1625
+ if (amountUdvpn && typeof amountUdvpn === 'object') {
1626
+ amountStr = String(amountUdvpn.amount || amountUdvpn.value || amountUdvpn);
1627
+ } else {
1628
+ amountStr = String(amountUdvpn);
1629
+ }
1630
+ if (!amountStr || amountStr === 'undefined' || amountStr === 'null' || amountStr === '[object Object]') {
1631
+ throw new Error(`sendTokens: invalid amount "${amountUdvpn}" — expected string or number, got ${typeof amountUdvpn}`);
1632
+ }
1633
+ const msg = {
1634
+ typeUrl: '/cosmos.bank.v1beta1.MsgSend',
1635
+ value: { fromAddress, toAddress, amount: [{ denom: 'udvpn', amount: amountStr }] },
1636
+ };
1637
+ return broadcast(client, fromAddress, [msg]);
1638
+ }
1639
+
1640
+ /**
1641
+ * Subscribe to a plan. Returns subscription ID from TX events.
1642
+ * @param {SigningStargateClient} client
1643
+ * @param {string} fromAddress
1644
+ * @param {number|string|bigint} planId
1645
+ * @param {string} [denom='udvpn']
1646
+ * @returns {Promise<{ subscriptionId: bigint, txHash: string }>}
1647
+ */
1648
+ export async function subscribeToPlan(client, fromAddress, planId, denom = 'udvpn') {
1649
+ const msg = {
1650
+ typeUrl: MSG_TYPES_OBJ.START_SUBSCRIPTION,
1651
+ value: { from: fromAddress, id: BigInt(planId), denom, renewalPricePolicy: 0 },
1652
+ };
1653
+ const result = await broadcast(client, fromAddress, [msg]);
1654
+ const subId = extractId(result, /subscription/i, ['subscription_id', 'id']);
1655
+ if (!subId) throw new ChainError(ErrorCodes.SESSION_EXTRACT_FAILED, 'Failed to extract subscription ID from TX events', { txHash: result.transactionHash });
1656
+ return { subscriptionId: BigInt(subId), txHash: result.transactionHash };
1657
+ }
1658
+
1659
+ /**
1660
+ * Get provider details by address.
1661
+ * @param {string} provAddress - sentprov1... address
1662
+ * @param {object} [opts]
1663
+ * @param {string} [opts.lcdUrl]
1664
+ * @returns {Promise<object|null>}
1665
+ */
1666
+ export async function getProviderByAddress(provAddress, opts = {}) {
1667
+ try {
1668
+ const data = await lcdQuery(`/sentinel/provider/v2/providers/${provAddress}`, opts);
1669
+ return data.provider || null;
1670
+ } catch { return null; }
1671
+ }
1672
+
1673
+ /**
1674
+ * Build batch MsgSend messages for token distribution.
1675
+ * @param {string} fromAddress
1676
+ * @param {Array<{ address: string, amountUdvpn: number|string }>} recipients
1677
+ * @returns {Array<{ typeUrl: string, value: object }>}
1678
+ */
1679
+ export function buildBatchSend(fromAddress, recipients) {
1680
+ return recipients.map(r => ({
1681
+ typeUrl: '/cosmos.bank.v1beta1.MsgSend',
1682
+ value: { fromAddress, toAddress: r.address, amount: [{ denom: 'udvpn', amount: String(r.amountUdvpn) }] },
1683
+ }));
1684
+ }
1685
+
1686
+ /**
1687
+ * Build batch MsgLinkNode messages for linking nodes to a plan.
1688
+ * @param {string} provAddress - sentprov1... address
1689
+ * @param {number|string|bigint} planId
1690
+ * @param {string[]} nodeAddresses - sentnode1... addresses
1691
+ * @returns {Array<{ typeUrl: string, value: object }>}
1692
+ */
1693
+ export function buildBatchLink(provAddress, planId, nodeAddresses) {
1694
+ return nodeAddresses.map(addr => ({
1695
+ typeUrl: '/sentinel.plan.v3.MsgLinkNodeRequest',
1696
+ value: { from: provAddress, id: BigInt(planId), node_address: addr },
1697
+ }));
1698
+ }
1699
+
1700
+ /**
1701
+ * Decode base64-encoded TX events into readable key-value pairs.
1702
+ * @param {Array} events - TX result events array
1703
+ * @returns {Array<{ type: string, attributes: Array<{ key: string, value: string }> }>}
1704
+ */
1705
+ export function decodeTxEvents(events) {
1706
+ return (events || []).map(e => ({
1707
+ type: e.type,
1708
+ attributes: (e.attributes || []).map(a => ({
1709
+ key: typeof a.key === 'string' ? a.key : Buffer.from(a.key, 'base64').toString('utf8'),
1710
+ value: typeof a.value === 'string' ? a.value : Buffer.from(a.value, 'base64').toString('utf8'),
1711
+ })),
1712
+ }));
1713
+ }
1714
+
1715
+ /**
1716
+ * Extract ALL session IDs from a batch TX result (multiple MsgStartSession).
1717
+ * @param {DeliverTxResponse} txResult
1718
+ * @returns {bigint[]}
1719
+ */
1720
+ export function extractAllSessionIds(txResult) {
1721
+ const ids = [];
1722
+ const seen = new Set();
1723
+ const decoded = decodeTxEvents(txResult.events || []);
1724
+ for (const evt of decoded) {
1725
+ if (/session/i.test(evt.type)) {
1726
+ for (const attr of evt.attributes) {
1727
+ if (attr.key === 'session_id' || attr.key === 'SessionID' || attr.key === 'id') {
1728
+ try {
1729
+ const id = BigInt(attr.value.replace(/"/g, '')); // strip quotes from base64-decoded values
1730
+ if (id > 0n && !seen.has(id)) { seen.add(id); ids.push(id); }
1731
+ } catch {}
1732
+ }
1733
+ }
1734
+ }
1735
+ }
1736
+ return ids;
1737
+ }
1738
+
1739
+ /**
1740
+ * Estimate gas fee for a batch of messages.
1741
+ * @param {number} msgCount
1742
+ * @param {string} [msgType='startSession'] - 'startSession' | 'feeGrant' | 'send'
1743
+ * @returns {{ gas: number, amount: number, fee: { amount: Array<{ denom: string, amount: string }>, gas: string } }}
1744
+ */
1745
+ export function estimateBatchFee(msgCount, msgType = 'startSession') {
1746
+ const gasPerMsg = { startSession: 200000, feeGrant: 150000, send: 80000, link: 150000 };
1747
+ const base = gasPerMsg[msgType] || 200000;
1748
+ const gas = base * msgCount;
1749
+ const amount = Math.ceil(gas * 0.2); // GAS_PRICE = 0.2udvpn
1750
+ return {
1751
+ gas,
1752
+ amount,
1753
+ fee: { amount: [{ denom: 'udvpn', amount: String(amount) }], gas: String(gas) },
1754
+ };
1755
+ }
1756
+
1757
+ /**
1758
+ * Estimate the cost of starting a session with a node.
1759
+ * Supports both gigabyte and hourly pricing. When preferHourly is true and
1760
+ * hourly pricing is available and cheaper, returns the hourly cost instead.
1761
+ *
1762
+ * @param {object} nodeInfo - Node LCD object with gigabyte_prices and optionally hourly_prices
1763
+ * @param {number} [gigabytes=1] - Number of gigabytes (ignored when hourly pricing is selected)
1764
+ * @param {{ preferHourly?: boolean, hours?: number }} [options] - Optional pricing mode
1765
+ * @returns {{ udvpn: number, dvpn: number, gasUdvpn: number, totalUdvpn: number, mode: 'gigabyte'|'hourly', hourlyUdvpn?: number, gigabyteUdvpn?: number }}
1766
+ */
1767
+ export function estimateSessionCost(nodeInfo, gigabytes = 1, options = {}) {
1768
+ const gbPrices = nodeInfo.gigabyte_prices || nodeInfo.gigabytePrices || [];
1769
+ const gbEntry = gbPrices.find(p => p.denom === 'udvpn');
1770
+ const perGb = parseInt(gbEntry?.quote_value || gbEntry?.amount || '0', 10);
1771
+
1772
+ const hrPrices = nodeInfo.hourly_prices || nodeInfo.hourlyPrices || [];
1773
+ const hrEntry = hrPrices.find(p => p.denom === 'udvpn');
1774
+ const perHour = parseInt(hrEntry?.quote_value || hrEntry?.amount || '0', 10);
1775
+
1776
+ const hours = options.hours || 1;
1777
+ const gbCost = perGb * gigabytes;
1778
+ const hrCost = perHour * hours;
1779
+
1780
+ // Use hourly if preferHourly is set AND hourly pricing exists AND is cheaper
1781
+ const useHourly = options.preferHourly && hrEntry && hrCost < gbCost;
1782
+ const sessionCost = useHourly ? hrCost : gbCost;
1783
+ const gasEstimate = 200000; // ~200k gas per MsgStartSession
1784
+
1785
+ return {
1786
+ udvpn: sessionCost,
1787
+ dvpn: sessionCost / 1_000_000,
1788
+ gasUdvpn: gasEstimate,
1789
+ totalUdvpn: sessionCost + gasEstimate,
1790
+ mode: useHourly ? 'hourly' : 'gigabyte',
1791
+ hourlyUdvpn: perHour || null,
1792
+ gigabyteUdvpn: perGb || null,
1793
+ };
1794
+ }
1795
+
1796
+ /**
1797
+ * Compare two addresses across different bech32 prefixes (sent1, sentprov1, sentnode1).
1798
+ * Returns true if they derive from the same public key.
1799
+ * @param {string} addr1
1800
+ * @param {string} addr2
1801
+ * @returns {boolean}
1802
+ */
1803
+ export function isSameKey(addr1, addr2) {
1804
+ try {
1805
+ const { data: d1 } = fromBech32(addr1);
1806
+ const { data: d2 } = fromBech32(addr2);
1807
+ return Buffer.from(d1).equals(Buffer.from(d2));
1808
+ } catch { return false; }
1809
+ }
1810
+
1811
+ // Internal ref for subscribeToPlan (avoids circular ref with MSG_TYPES below)
1812
+ const MSG_TYPES_OBJ = { START_SUBSCRIPTION: '/sentinel.subscription.v3.MsgStartSubscriptionRequest' };
1813
+
1814
+ // ─── All Type URL Constants ──────────────────────────────────────────────────
1815
+
1816
+ export const MSG_TYPES = {
1817
+ // Direct node session
1818
+ START_SESSION: '/sentinel.node.v3.MsgStartSessionRequest',
1819
+ END_SESSION: '/sentinel.session.v3.MsgCancelSessionRequest',
1820
+ // Subscription
1821
+ START_SUBSCRIPTION: '/sentinel.subscription.v3.MsgStartSubscriptionRequest',
1822
+ SUB_START_SESSION: '/sentinel.subscription.v3.MsgStartSessionRequest',
1823
+ // Plan
1824
+ PLAN_START_SESSION: '/sentinel.plan.v3.MsgStartSessionRequest',
1825
+ CREATE_PLAN: '/sentinel.plan.v3.MsgCreatePlanRequest',
1826
+ UPDATE_PLAN_STATUS: '/sentinel.plan.v3.MsgUpdatePlanStatusRequest',
1827
+ LINK_NODE: '/sentinel.plan.v3.MsgLinkNodeRequest',
1828
+ UNLINK_NODE: '/sentinel.plan.v3.MsgUnlinkNodeRequest',
1829
+ // Provider
1830
+ REGISTER_PROVIDER: '/sentinel.provider.v3.MsgRegisterProviderRequest',
1831
+ UPDATE_PROVIDER: '/sentinel.provider.v3.MsgUpdateProviderDetailsRequest',
1832
+ UPDATE_PROVIDER_STATUS: '/sentinel.provider.v3.MsgUpdateProviderStatusRequest',
1833
+ // Plan details update (v3 — NEW)
1834
+ UPDATE_PLAN_DETAILS: '/sentinel.plan.v3.MsgUpdatePlanDetailsRequest',
1835
+ // Lease
1836
+ START_LEASE: '/sentinel.lease.v1.MsgStartLeaseRequest',
1837
+ END_LEASE: '/sentinel.lease.v1.MsgEndLeaseRequest',
1838
+ // Subscription management (v3)
1839
+ CANCEL_SUBSCRIPTION: '/sentinel.subscription.v3.MsgCancelSubscriptionRequest',
1840
+ RENEW_SUBSCRIPTION: '/sentinel.subscription.v3.MsgRenewSubscriptionRequest',
1841
+ SHARE_SUBSCRIPTION: '/sentinel.subscription.v3.MsgShareSubscriptionRequest',
1842
+ UPDATE_SUBSCRIPTION: '/sentinel.subscription.v3.MsgUpdateSubscriptionRequest',
1843
+ // Session management (v3)
1844
+ UPDATE_SESSION: '/sentinel.session.v3.MsgUpdateSessionRequest',
1845
+ // Node operator (v3)
1846
+ REGISTER_NODE: '/sentinel.node.v3.MsgRegisterNodeRequest',
1847
+ UPDATE_NODE_DETAILS: '/sentinel.node.v3.MsgUpdateNodeDetailsRequest',
1848
+ UPDATE_NODE_STATUS: '/sentinel.node.v3.MsgUpdateNodeStatusRequest',
1849
+ // Cosmos FeeGrant
1850
+ GRANT_FEE_ALLOWANCE: '/cosmos.feegrant.v1beta1.MsgGrantAllowance',
1851
+ REVOKE_FEE_ALLOWANCE: '/cosmos.feegrant.v1beta1.MsgRevokeAllowance',
1852
+ // Cosmos Authz
1853
+ AUTHZ_GRANT: '/cosmos.authz.v1beta1.MsgGrant',
1854
+ AUTHZ_REVOKE: '/cosmos.authz.v1beta1.MsgRevoke',
1855
+ AUTHZ_EXEC: '/cosmos.authz.v1beta1.MsgExec',
1856
+ };
1857
+
1858
+ // ─── VPN Settings Persistence ────────────────────────────────────────────────
1859
+ // v27: Persistent user settings (backported from C# VpnSettings.cs).
1860
+ // Stores preferences in ~/.sentinel-sdk/settings.json with restrictive permissions.
1861
+
1862
+ const SETTINGS_FILE = path.join(os.homedir(), '.sentinel-sdk', 'settings.json');
1863
+
1864
+ /**
1865
+ * Load persisted VPN settings from disk.
1866
+ * Returns empty object if file doesn't exist or is corrupt.
1867
+ * @returns {Record<string, any>}
1868
+ */
1869
+ export function loadVpnSettings() {
1870
+ try {
1871
+ if (!existsSync(SETTINGS_FILE)) return {};
1872
+ return JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8'));
1873
+ } catch { return {}; }
1874
+ }
1875
+
1876
+ /**
1877
+ * Save VPN settings to disk. Creates ~/.sentinel-sdk/ if needed.
1878
+ * @param {Record<string, any>} settings
1879
+ */
1880
+ export function saveVpnSettings(settings) {
1881
+ const dir = path.dirname(SETTINGS_FILE);
1882
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
1883
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });
1884
+ }