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
package/chain/index.js ADDED
@@ -0,0 +1,891 @@
1
+ /**
2
+ * Sentinel SDK — Chain / Blockchain Module
3
+ *
4
+ * CosmJS client creation, transaction broadcasting, LCD queries, registry
5
+ * building, FeeGrant, Authz, and all chain-interaction helpers.
6
+ *
7
+ * Extracted from cosmjs-setup.js during v22 modularization.
8
+ *
9
+ * Usage:
10
+ * import { createClient, broadcast, lcd, MSG_TYPES } from './chain/index.js';
11
+ * const client = await createClient(rpcUrl, wallet);
12
+ * const result = await broadcast(client, addr, [msg]);
13
+ */
14
+
15
+ import { Registry } from '@cosmjs/proto-signing';
16
+ import { SigningStargateClient, GasPrice, defaultRegistryTypes } from '@cosmjs/stargate';
17
+ import axios from 'axios';
18
+
19
+ // Protocol — protobuf encoders for Sentinel message types
20
+ import {
21
+ encodeMsgStartSession,
22
+ encodeMsgStartSubscription,
23
+ encodeMsgSubStartSession,
24
+ extractSessionId,
25
+ encodeVarint, protoString, protoInt64, protoEmbedded,
26
+ } from '../protocol/index.js';
27
+
28
+ import {
29
+ encodeMsgRegisterProvider,
30
+ encodeMsgUpdateProviderDetails,
31
+ encodeMsgUpdateProviderStatus,
32
+ encodeMsgCreatePlan,
33
+ encodeMsgUpdatePlanStatus,
34
+ encodeMsgLinkNode,
35
+ encodeMsgUnlinkNode,
36
+ encodeMsgPlanStartSession,
37
+ encodeMsgStartLease,
38
+ encodeMsgEndLease,
39
+ } from '../protocol/index.js';
40
+
41
+ // Config — gas price, LCD endpoints, fallback logic
42
+ import { GAS_PRICE, LCD_ENDPOINTS, tryWithFallback } from '../config/index.js';
43
+
44
+ // Errors — typed error classes
45
+ import { ValidationError, NodeError, ErrorCodes } from '../errors/index.js';
46
+
47
+ // Security — CA-validated HTTPS agent for public LCD/RPC endpoints
48
+ import { publicEndpointAgent } from '../security/index.js';
49
+
50
+ // Wallet — validation helpers (used by chain functions that validate addresses)
51
+ import { validateMnemonic, validateAddress } from '../wallet/index.js';
52
+
53
+ // ─── All Type URL Constants ──────────────────────────────────────────────────
54
+
55
+ export const MSG_TYPES = {
56
+ // Direct node session
57
+ START_SESSION: '/sentinel.node.v3.MsgStartSessionRequest',
58
+ // Subscription
59
+ START_SUBSCRIPTION: '/sentinel.subscription.v3.MsgStartSubscriptionRequest',
60
+ SUB_START_SESSION: '/sentinel.subscription.v3.MsgStartSessionRequest',
61
+ // Plan
62
+ PLAN_START_SESSION: '/sentinel.plan.v3.MsgStartSessionRequest',
63
+ CREATE_PLAN: '/sentinel.plan.v3.MsgCreatePlanRequest',
64
+ UPDATE_PLAN_STATUS: '/sentinel.plan.v3.MsgUpdatePlanStatusRequest',
65
+ LINK_NODE: '/sentinel.plan.v3.MsgLinkNodeRequest',
66
+ UNLINK_NODE: '/sentinel.plan.v3.MsgUnlinkNodeRequest',
67
+ // Provider
68
+ REGISTER_PROVIDER: '/sentinel.provider.v3.MsgRegisterProviderRequest',
69
+ UPDATE_PROVIDER: '/sentinel.provider.v3.MsgUpdateProviderDetailsRequest',
70
+ UPDATE_PROVIDER_STATUS: '/sentinel.provider.v3.MsgUpdateProviderStatusRequest',
71
+ // Lease
72
+ START_LEASE: '/sentinel.lease.v1.MsgStartLeaseRequest',
73
+ END_LEASE: '/sentinel.lease.v1.MsgEndLeaseRequest',
74
+ // Cosmos FeeGrant
75
+ GRANT_FEE_ALLOWANCE: '/cosmos.feegrant.v1beta1.MsgGrantAllowance',
76
+ REVOKE_FEE_ALLOWANCE: '/cosmos.feegrant.v1beta1.MsgRevokeAllowance',
77
+ // Cosmos Authz
78
+ AUTHZ_GRANT: '/cosmos.authz.v1beta1.MsgGrant',
79
+ AUTHZ_REVOKE: '/cosmos.authz.v1beta1.MsgRevoke',
80
+ AUTHZ_EXEC: '/cosmos.authz.v1beta1.MsgExec',
81
+ };
82
+
83
+ // ─── CosmJS Registry ─────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Adapter that wraps a manual protobuf encoder for CosmJS's Registry.
87
+ * CosmJS expects { fromPartial, encode, decode } — we only need encode.
88
+ */
89
+ function makeMsgType(encodeFn) {
90
+ return {
91
+ fromPartial: (v) => v,
92
+ encode: (inst) => ({ finish: () => encodeFn(inst) }),
93
+ decode: () => ({}),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Build a CosmJS Registry with ALL 13 Sentinel message types registered.
99
+ * This is required for signAndBroadcast to encode Sentinel-specific messages.
100
+ */
101
+ export function buildRegistry() {
102
+ return new Registry([
103
+ ...defaultRegistryTypes,
104
+ // Direct node session (v3protocol.js)
105
+ ['/sentinel.node.v3.MsgStartSessionRequest', makeMsgType(encodeMsgStartSession)],
106
+ // Subscription (v3protocol.js)
107
+ ['/sentinel.subscription.v3.MsgStartSubscriptionRequest', makeMsgType(encodeMsgStartSubscription)],
108
+ ['/sentinel.subscription.v3.MsgStartSessionRequest', makeMsgType(encodeMsgSubStartSession)],
109
+ // Plan (plan-operations.js)
110
+ ['/sentinel.plan.v3.MsgStartSessionRequest', makeMsgType(encodeMsgPlanStartSession)],
111
+ ['/sentinel.plan.v3.MsgCreatePlanRequest', makeMsgType(encodeMsgCreatePlan)],
112
+ ['/sentinel.plan.v3.MsgLinkNodeRequest', makeMsgType(encodeMsgLinkNode)],
113
+ ['/sentinel.plan.v3.MsgUnlinkNodeRequest', makeMsgType(encodeMsgUnlinkNode)],
114
+ ['/sentinel.plan.v3.MsgUpdatePlanStatusRequest', makeMsgType(encodeMsgUpdatePlanStatus)],
115
+ // Provider (plan-operations.js)
116
+ ['/sentinel.provider.v3.MsgRegisterProviderRequest', makeMsgType(encodeMsgRegisterProvider)],
117
+ ['/sentinel.provider.v3.MsgUpdateProviderDetailsRequest', makeMsgType(encodeMsgUpdateProviderDetails)],
118
+ ['/sentinel.provider.v3.MsgUpdateProviderStatusRequest', makeMsgType(encodeMsgUpdateProviderStatus)],
119
+ // Lease (plan-operations.js)
120
+ ['/sentinel.lease.v1.MsgStartLeaseRequest', makeMsgType(encodeMsgStartLease)],
121
+ ['/sentinel.lease.v1.MsgEndLeaseRequest', makeMsgType(encodeMsgEndLease)],
122
+ ]);
123
+ }
124
+
125
+ // ─── Signing Client ──────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Create a SigningStargateClient connected to Sentinel RPC.
129
+ * Gas price: from defaults.js GAS_PRICE (chain minimum).
130
+ */
131
+ export async function createClient(rpcUrl, wallet) {
132
+ return SigningStargateClient.connectWithSigner(rpcUrl, wallet, {
133
+ gasPrice: GasPrice.fromString(GAS_PRICE),
134
+ registry: buildRegistry(),
135
+ });
136
+ }
137
+
138
+ // ─── TX Helpers ──────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Simple broadcast — send messages and return result.
142
+ * For production apps with multiple TXs, use createSafeBroadcaster() instead.
143
+ */
144
+ export async function broadcast(client, signerAddress, msgs, fee = null) {
145
+ if (!fee) fee = 'auto';
146
+ let result;
147
+ try {
148
+ result = await client.signAndBroadcast(signerAddress, msgs, fee);
149
+ } catch (err) {
150
+ // CosmJS on Node.js v18+ uses native fetch (undici) internally for RPC.
151
+ // Undici throws opaque "fetch failed" on network errors. Re-wrap with context.
152
+ const typeUrls = msgs.map(m => m.typeUrl).join(', ');
153
+ throw new Error(`Broadcast failed (${typeUrls}): ${err.message}`);
154
+ }
155
+ if (result.code !== 0) throw new Error(`TX failed (code ${result.code}): ${result.rawLog}`);
156
+ return result;
157
+ }
158
+
159
+ // ─── Safe Broadcast (Mutex + Retry + Sequence Recovery) ─────────────────────
160
+ // Production-critical: prevents sequence mismatch errors when sending
161
+ // multiple TXs rapidly (batch operations, auto-lease + link, UI clicks).
162
+
163
+ export function isSequenceError(errOrStr) {
164
+ // Check Cosmos SDK error code 32 (ErrWrongSequence) first
165
+ if (errOrStr?.code === 32) return true;
166
+ const s = typeof errOrStr === 'string' ? errOrStr : errOrStr?.message || String(errOrStr);
167
+ // Try parsing rawLog as JSON to extract error code
168
+ try { const parsed = JSON.parse(s); if (parsed?.code === 32) return true; } catch {} // not JSON — fall through to string match
169
+ // Fallback to string match (last resort — fragile across Cosmos SDK upgrades)
170
+ return s && (s.includes('account sequence mismatch') || s.includes('incorrect account sequence'));
171
+ }
172
+
173
+ function extractExpectedSeq(s) {
174
+ const m = String(s).match(/expected\s+(\d+)/);
175
+ return m ? parseInt(m[1]) : null;
176
+ }
177
+
178
+ /**
179
+ * Create a safe broadcaster with mutex serialization and retry logic.
180
+ * Only one TX broadcasts at a time. Sequence errors trigger client reconnect + retry.
181
+ *
182
+ * Usage:
183
+ * const { safeBroadcast } = createSafeBroadcaster(rpcUrl, wallet, signerAddress);
184
+ * const result = await safeBroadcast([msg1, msg2]); // batch = one TX
185
+ */
186
+ export function createSafeBroadcaster(rpcUrl, wallet, signerAddress) {
187
+ let _client = null;
188
+ let _queue = Promise.resolve();
189
+
190
+ async function getClient() {
191
+ if (!_client) {
192
+ _client = await SigningStargateClient.connectWithSigner(rpcUrl, wallet, {
193
+ gasPrice: GasPrice.fromString(GAS_PRICE),
194
+ registry: buildRegistry(),
195
+ });
196
+ }
197
+ return _client;
198
+ }
199
+
200
+ async function resetClient() {
201
+ _client = await SigningStargateClient.connectWithSigner(rpcUrl, wallet, {
202
+ gasPrice: GasPrice.fromString(GAS_PRICE),
203
+ registry: buildRegistry(),
204
+ });
205
+ return _client;
206
+ }
207
+
208
+ async function _inner(msgs, memo) {
209
+ for (let attempt = 0; attempt < 5; attempt++) {
210
+ let client;
211
+ if (attempt === 0) {
212
+ client = await getClient();
213
+ } else {
214
+ const delay = Math.min(2000 * attempt, 6000);
215
+ await new Promise(r => setTimeout(r, delay));
216
+ client = await resetClient(); // fresh connection = fresh sequence
217
+ }
218
+
219
+ try {
220
+ const result = await client.signAndBroadcast(signerAddress, msgs, 'auto', memo);
221
+ if (result.code !== 0 && isSequenceError(result.rawLog)) continue;
222
+ return result;
223
+ } catch (err) {
224
+ if (isSequenceError(err.message)) continue;
225
+ throw err;
226
+ }
227
+ }
228
+ // Final attempt
229
+ await new Promise(r => setTimeout(r, 4000));
230
+ const client = await resetClient();
231
+ return client.signAndBroadcast(signerAddress, msgs, 'auto', memo);
232
+ }
233
+
234
+ function safeBroadcast(msgs, memo) {
235
+ const p = _queue.then(() => _inner(msgs, memo));
236
+ _queue = p.catch(() => {}); // don't break queue on failure
237
+ return p;
238
+ }
239
+
240
+ return { safeBroadcast, getClient, resetClient };
241
+ }
242
+
243
+ /**
244
+ * Parse chain error messages into user-friendly text.
245
+ * Covers all known Sentinel-specific error patterns.
246
+ */
247
+ export function parseChainError(raw) {
248
+ const s = String(raw || '');
249
+ if (s.includes('duplicate node for plan')) return 'Node is already in this plan';
250
+ if (s.includes('duplicate provider')) return 'Provider already registered — use Update';
251
+ if (s.includes('lease') && s.includes('not found')) return 'No active lease for this node';
252
+ if (s.includes('lease') && s.includes('already exists')) return 'Lease already exists for this node';
253
+ if (s.includes('insufficient funds')) return 'Insufficient P2P balance';
254
+ if (s.includes('invalid price')) return 'Price mismatch — node may have changed rates';
255
+ if (s.includes('invalid status inactive')) return 'Plan is inactive — activate first';
256
+ if (s.includes('plan') && s.includes('does not exist')) return 'Plan not found on chain';
257
+ if (s.includes('provider') && s.includes('does not exist')) return 'Provider not registered';
258
+ if (s.includes('node') && s.includes('does not exist')) return 'Node not found on chain';
259
+ if (s.includes('node') && s.includes('not active')) return 'Node is inactive';
260
+ if (isSequenceError(s)) return 'Chain busy — sequence mismatch. Wait and retry.';
261
+ if (s.includes('out of gas')) return 'Transaction out of gas';
262
+ if (s.includes('timed out')) return 'Transaction timed out';
263
+ const m = s.match(/desc = (.+?)(?:\[|With gas|$)/);
264
+ if (m) return m[1].trim().slice(0, 120);
265
+ return s.slice(0, 150);
266
+ }
267
+
268
+ /**
269
+ * Extract an ID from TX ABCI events.
270
+ * Events may have base64-encoded keys/values depending on CosmJS version.
271
+ *
272
+ * Usage:
273
+ * extractId(result, /session/i, ['session_id', 'id'])
274
+ * extractId(result, /subscription/i, ['subscription_id', 'id'])
275
+ * extractId(result, /plan/i, ['plan_id', 'id'])
276
+ * extractId(result, /lease/i, ['lease_id', 'id'])
277
+ */
278
+ export function extractId(txResult, eventPattern, keyNames) {
279
+ for (const event of (txResult.events || [])) {
280
+ if (eventPattern.test(event.type)) {
281
+ for (const attr of event.attributes) {
282
+ const k = typeof attr.key === 'string'
283
+ ? attr.key
284
+ : Buffer.from(attr.key, 'base64').toString('utf8');
285
+ const v = typeof attr.value === 'string'
286
+ ? attr.value
287
+ : Buffer.from(attr.value, 'base64').toString('utf8');
288
+ if (keyNames.includes(k)) {
289
+ const p = v.replace(/"/g, '');
290
+ if (p && parseInt(p) > 0) return p;
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return null;
296
+ }
297
+
298
+ // ─── LCD Query Helper ────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Query a Sentinel LCD REST endpoint.
302
+ * Checks both HTTP status AND gRPC error codes in response body.
303
+ * Uses CA-validated HTTPS for LCD public infrastructure (valid CA certs).
304
+ *
305
+ * Usage:
306
+ * const data = await lcd('https://lcd.sentinel.co', '/sentinel/node/v3/nodes?status=1');
307
+ */
308
+ export async function lcd(baseUrl, path) {
309
+ const url = `${baseUrl}${path}`;
310
+ const res = await axios.get(url, { httpsAgent: publicEndpointAgent, timeout: 15000 });
311
+ const data = res.data;
312
+ if (data?.code && data.code !== 0) {
313
+ throw new Error(`LCD ${path}: code=${data.code} ${data.message || ''}`);
314
+ }
315
+ return data;
316
+ }
317
+
318
+ // ─── Query Helpers ───────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Check wallet balance.
322
+ * Returns { udvpn: number, dvpn: number }
323
+ */
324
+ export async function getBalance(client, address) {
325
+ const bal = await client.getBalance(address, 'udvpn');
326
+ const amount = parseInt(bal?.amount || '0', 10) || 0;
327
+ return { udvpn: amount, dvpn: amount / 1_000_000 };
328
+ }
329
+
330
+ /**
331
+ * BigInt-safe serialization of TX result (for logging/API responses).
332
+ */
333
+ export function txResponse(result) {
334
+ return {
335
+ ok: result.code === 0,
336
+ txHash: result.transactionHash,
337
+ gasUsed: Number(result.gasUsed),
338
+ gasWanted: Number(result.gasWanted),
339
+ code: result.code,
340
+ rawLog: result.rawLog,
341
+ events: result.events,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Find an existing active session for a wallet+node pair.
347
+ * Returns session ID (BigInt) or null. Use this to avoid double-paying.
348
+ *
349
+ * Note: Sessions have a nested base_session object containing the actual data.
350
+ */
351
+ export async function findExistingSession(lcdUrl, walletAddr, nodeAddr) {
352
+ const data = await lcd(lcdUrl, `/sentinel/session/v3/sessions?address=${walletAddr}&status=1&pagination.limit=100`);
353
+ for (const s of (data.sessions || [])) {
354
+ const bs = s.base_session || s; // session data is nested in base_session
355
+ if (bs.node_address !== nodeAddr) continue;
356
+ const maxBytes = parseInt(bs.max_bytes || '0');
357
+ const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
358
+ if (maxBytes === 0 || used < maxBytes) return BigInt(bs.id);
359
+ }
360
+ return null;
361
+ }
362
+
363
+ /**
364
+ * Resolve LCD node object to an HTTPS URL.
365
+ * LCD v3 returns `remote_addrs: ["IP:PORT"]` (array, NO protocol prefix).
366
+ * Legacy responses may have `remote_url: "https://IP:PORT"` (string with prefix).
367
+ * This handles both formats.
368
+ */
369
+ export function resolveNodeUrl(node) {
370
+ // Try legacy field first (string with https://)
371
+ if (node.remote_url && typeof node.remote_url === 'string') return node.remote_url;
372
+ // v3 LCD: remote_addrs is an array of "IP:PORT" strings
373
+ const addrs = node.remote_addrs || [];
374
+ const raw = addrs.find(a => a.includes(':')) || addrs[0];
375
+ if (!raw) throw new Error(`Node ${node.address} has no remote_addrs`);
376
+ return raw.startsWith('http') ? raw : `https://${raw}`;
377
+ }
378
+
379
+ /**
380
+ * Fetch all active nodes from LCD with pagination.
381
+ * Returns array of node objects. Each node has `remote_url` resolved from `remote_addrs`.
382
+ */
383
+ export async function fetchActiveNodes(lcdUrl, limit = 500, maxPages = 20) {
384
+ const nodes = [];
385
+ let nextKey = null;
386
+ let page = 0;
387
+ do {
388
+ const keyParam = nextKey ? `&pagination.key=${encodeURIComponent(nextKey)}` : '';
389
+ const data = await lcd(lcdUrl, `/sentinel/node/v3/nodes?status=1&pagination.limit=${limit}${keyParam}`);
390
+ nodes.push(...(data.nodes || []));
391
+ nextKey = data.pagination?.next_key || null;
392
+ page++;
393
+ } while (nextKey && page < maxPages);
394
+ // Add computed remote_url for backward compatibility
395
+ for (const n of nodes) {
396
+ try { n.remote_url = resolveNodeUrl(n); } catch { /* skip nodes with no address */ }
397
+ }
398
+ return nodes;
399
+ }
400
+
401
+ /**
402
+ * Get a quick network overview — total nodes, counts by country and service type, average prices.
403
+ * Perfect for dashboard UIs, onboarding screens, and network health displays.
404
+ *
405
+ * @param {string} [lcdUrl] - LCD endpoint (default: cascading fallback)
406
+ * @returns {Promise<{ totalNodes: number, byCountry: Array<{country: string, count: number}>, byType: {wireguard: number, v2ray: number, unknown: number}, averagePrice: {gigabyteDvpn: number, hourlyDvpn: number}, nodes: Array }>}
407
+ *
408
+ * @example
409
+ * const overview = await getNetworkOverview();
410
+ * console.log(`${overview.totalNodes} nodes across ${overview.byCountry.length} countries`);
411
+ * console.log(`Average: ${overview.averagePrice.gigabyteDvpn.toFixed(3)} P2P/GB`);
412
+ */
413
+ export async function getNetworkOverview(lcdUrl) {
414
+ const fetchFn = async (url) => fetchActiveNodes(url);
415
+ let nodes;
416
+ if (lcdUrl) {
417
+ nodes = await fetchFn(lcdUrl);
418
+ } else {
419
+ const result = await tryWithFallback(LCD_ENDPOINTS, fetchFn, 'getNetworkOverview');
420
+ nodes = result.result;
421
+ }
422
+
423
+ // Filter to nodes that accept udvpn
424
+ const active = nodes.filter(n => n.remote_url && (n.gigabyte_prices || []).some(p => p.denom === 'udvpn'));
425
+
426
+ // Count by country (from LCD metadata, limited — enrichNodes gives better data)
427
+ const countryMap = {};
428
+ for (const n of active) {
429
+ const c = n.location?.country || n.country || 'Unknown';
430
+ countryMap[c] = (countryMap[c] || 0) + 1;
431
+ }
432
+ const byCountry = Object.entries(countryMap)
433
+ .map(([country, count]) => ({ country, count }))
434
+ .sort((a, b) => b.count - a.count);
435
+
436
+ // Count by type (type not in LCD — estimate from service_type field if present)
437
+ const byType = { wireguard: 0, v2ray: 0, unknown: 0 };
438
+ for (const n of active) {
439
+ const t = n.service_type || n.type;
440
+ if (t === 'wireguard' || t === 1) byType.wireguard++;
441
+ else if (t === 'v2ray' || t === 2) byType.v2ray++;
442
+ else byType.unknown++;
443
+ }
444
+
445
+ // Average prices
446
+ let gbTotal = 0, gbCount = 0, hrTotal = 0, hrCount = 0;
447
+ for (const n of active) {
448
+ const gb = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
449
+ if (gb?.quote_value) { gbTotal += parseInt(gb.quote_value, 10); gbCount++; }
450
+ const hr = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
451
+ if (hr?.quote_value) { hrTotal += parseInt(hr.quote_value, 10); hrCount++; }
452
+ }
453
+
454
+ return {
455
+ totalNodes: active.length,
456
+ byCountry,
457
+ byType,
458
+ averagePrice: {
459
+ gigabyteDvpn: gbCount > 0 ? (gbTotal / gbCount) / 1_000_000 : 0,
460
+ hourlyDvpn: hrCount > 0 ? (hrTotal / hrCount) / 1_000_000 : 0,
461
+ },
462
+ nodes: active,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Discover plan IDs by probing subscription endpoints.
468
+ * Workaround for /sentinel/plan/v3/plans returning 501 Not Implemented.
469
+ * Returns sorted array of plan IDs that have at least 1 subscription.
470
+ */
471
+ export async function discoverPlanIds(lcdUrl, maxId = 100) {
472
+ const ids = [];
473
+ const batchSize = 10;
474
+ for (let batch = 0; batch < maxId / batchSize; batch++) {
475
+ const checks = [];
476
+ for (let i = batch * batchSize + 1; i <= (batch + 1) * batchSize; i++) {
477
+ checks.push(
478
+ lcd(lcdUrl, `/sentinel/subscription/v3/plans/${i}/subscriptions?pagination.limit=1&pagination.count_total=true`)
479
+ .then(d => { if (parseInt(d.pagination?.total || '0') > 0) ids.push(i); })
480
+ .catch(() => {})
481
+ );
482
+ }
483
+ await Promise.all(checks);
484
+ }
485
+ return ids.sort((a, b) => a - b);
486
+ }
487
+
488
+ /**
489
+ * Get standardized prices for a node — abstracts V3 LCD price parsing entirely.
490
+ *
491
+ * Solves the common "NaN / GB" problem by defensively extracting quote_value,
492
+ * base_value, or amount from the nested LCD response structure.
493
+ *
494
+ * @param {string} nodeAddress - sentnode1... address
495
+ * @param {string} [lcdUrl] - LCD endpoint URL (default: cascading fallback across all endpoints)
496
+ * @returns {Promise<{ gigabyte: { dvpn: number, udvpn: number, raw: object|null }, hourly: { dvpn: number, udvpn: number, raw: object|null }, denom: string, nodeAddress: string }>}
497
+ *
498
+ * @example
499
+ * const prices = await getNodePrices('sentnode1abc...');
500
+ * console.log(`${prices.gigabyte.dvpn} P2P/GB, ${prices.hourly.dvpn} P2P/hr`);
501
+ * // Use prices.gigabyte.raw for the full { denom, base_value, quote_value } object
502
+ * // needed by encodeMsgStartSession's max_price field.
503
+ */
504
+ export async function getNodePrices(nodeAddress, lcdUrl) {
505
+ if (typeof nodeAddress !== 'string' || !/^sentnode1[a-z0-9]{38}$/.test(nodeAddress)) {
506
+ throw new ValidationError(ErrorCodes.INVALID_NODE_ADDRESS, 'nodeAddress must be a valid sentnode1... bech32 address (46 characters)', { value: nodeAddress });
507
+ }
508
+
509
+ const fetchNode = async (baseUrl) => {
510
+ let nextKey = null;
511
+ let pages = 0;
512
+ do {
513
+ const keyParam = nextKey ? `&pagination.key=${encodeURIComponent(nextKey)}` : '';
514
+ const data = await lcd(baseUrl, `/sentinel/node/v3/nodes?status=1&pagination.limit=500${keyParam}`);
515
+ const nodes = data.nodes || [];
516
+ const found = nodes.find(n => n.address === nodeAddress);
517
+ if (found) return found;
518
+ nextKey = data.pagination?.next_key || null;
519
+ pages++;
520
+ } while (nextKey && pages < 20);
521
+ return null;
522
+ };
523
+
524
+ let node;
525
+ if (lcdUrl) {
526
+ node = await fetchNode(lcdUrl);
527
+ } else {
528
+ const result = await tryWithFallback(LCD_ENDPOINTS, fetchNode, 'getNodePrices');
529
+ node = result.result;
530
+ }
531
+
532
+ if (!node) throw new NodeError(ErrorCodes.NODE_NOT_FOUND, `Node ${nodeAddress} not found on LCD (may be inactive or deregistered)`, { nodeAddress });
533
+
534
+ function extractPrice(priceArray) {
535
+ if (!Array.isArray(priceArray)) return { dvpn: 0, udvpn: 0, raw: null };
536
+ const entry = priceArray.find(p => p.denom === 'udvpn');
537
+ if (!entry) return { dvpn: 0, udvpn: 0, raw: null };
538
+ // Defensive fallback chain: quote_value (V3 current) -> base_value -> amount (legacy)
539
+ const rawVal = entry.quote_value || entry.base_value || entry.amount || '0';
540
+ const udvpn = parseInt(rawVal, 10) || 0;
541
+ return { dvpn: parseFloat((udvpn / 1_000_000).toFixed(6)), udvpn, raw: entry };
542
+ }
543
+
544
+ return {
545
+ gigabyte: extractPrice(node.gigabyte_prices),
546
+ hourly: extractPrice(node.hourly_prices),
547
+ denom: 'P2P',
548
+ nodeAddress,
549
+ };
550
+ }
551
+
552
+ // ─── Display & Serialization Helpers ────────────────────────────────────────
553
+
554
+ /**
555
+ * Format a micro-denom (udvpn) amount as a human-readable P2P string.
556
+ *
557
+ * @param {number|string} udvpn - Amount in micro-denom (1 P2P = 1,000,000 udvpn)
558
+ * @param {number} [decimals=2] - Decimal places to show
559
+ * @returns {string} e.g., "0.04 P2P", "47.69 P2P"
560
+ *
561
+ * @example
562
+ * formatDvpn(40152030); // "40.15 P2P"
563
+ * formatDvpn('1000000', 0); // "1 P2P"
564
+ * formatDvpn(500000, 4); // "0.5000 P2P"
565
+ */
566
+ export function formatDvpn(udvpn, decimals = 2) {
567
+ const val = Number(udvpn) / 1_000_000;
568
+ if (isNaN(val)) return '? P2P';
569
+ return `${val.toFixed(decimals)} P2P`;
570
+ }
571
+
572
+ /** Alias for formatDvpn — uses the new P2P token ticker. */
573
+ export const formatP2P = formatDvpn;
574
+
575
+ /**
576
+ * Filter a node list by country, service type, and/or max price.
577
+ * Works with results from listNodes(), enrichNodes(), or fetchAllNodes().
578
+ *
579
+ * @param {Array} nodes - Array of node objects
580
+ * @param {object} criteria
581
+ * @param {string} [criteria.country] - Country name (case-insensitive partial match)
582
+ * @param {string} [criteria.serviceType] - 'wireguard' or 'v2ray'
583
+ * @param {number} [criteria.maxPriceDvpn] - Maximum GB price in P2P (e.g., 0.1)
584
+ * @param {number} [criteria.minScore] - Minimum quality score (0-100)
585
+ * @returns {Array} Filtered nodes
586
+ *
587
+ * @example
588
+ * const cheap = filterNodes(nodes, { maxPriceDvpn: 0.05, serviceType: 'v2ray' });
589
+ * const german = filterNodes(nodes, { country: 'Germany' });
590
+ */
591
+ export function filterNodes(nodes, criteria = {}) {
592
+ if (!Array.isArray(nodes)) return [];
593
+ return nodes.filter(node => {
594
+ if (criteria.country) {
595
+ const c = (node.country || node.location?.country || '').toLowerCase();
596
+ if (!c.includes(criteria.country.toLowerCase())) return false;
597
+ }
598
+ if (criteria.serviceType) {
599
+ const t = node.serviceType || node.type || '';
600
+ if (t !== criteria.serviceType) return false;
601
+ }
602
+ if (criteria.maxPriceDvpn != null) {
603
+ const prices = node.gigabytePrices || node.gigabyte_prices || [];
604
+ const entry = prices.find(p => p.denom === 'udvpn');
605
+ if (entry) {
606
+ const dvpn = parseInt(entry.quote_value || entry.base_value || entry.amount || '0', 10) / 1_000_000;
607
+ if (dvpn > criteria.maxPriceDvpn) return false;
608
+ }
609
+ }
610
+ if (criteria.minScore != null && node.qualityScore != null) {
611
+ if (node.qualityScore < criteria.minScore) return false;
612
+ }
613
+ return true;
614
+ });
615
+ }
616
+
617
+ /**
618
+ * Serialize a ConnectResult for JSON APIs. Handles BigInt -> string conversion.
619
+ * Without this, JSON.stringify(connectResult) throws "BigInt can't be serialized".
620
+ *
621
+ * @param {object} result - ConnectResult from connectDirect/connectAuto/connectViaPlan
622
+ * @returns {object} JSON-safe object with sessionId as string
623
+ *
624
+ * @example
625
+ * const conn = await connectDirect(opts);
626
+ * res.json(serializeResult(conn)); // Safe for Express response
627
+ */
628
+ export function serializeResult(result) {
629
+ if (!result || typeof result !== 'object') return result;
630
+ const out = {};
631
+ for (const [key, val] of Object.entries(result)) {
632
+ if (typeof val === 'bigint') out[key] = String(val);
633
+ else if (typeof val === 'function') continue; // skip cleanup()
634
+ else out[key] = val;
635
+ }
636
+ return out;
637
+ }
638
+
639
+ // ─── P2P Price (CoinGecko) ──────────────────────────────────────────────────
640
+
641
+ let _dvpnPrice = null;
642
+ let _dvpnPriceAt = 0;
643
+
644
+ /**
645
+ * Get P2P token price in USD from CoinGecko (cached for 5 minutes).
646
+ */
647
+ export async function getDvpnPrice() {
648
+ if (_dvpnPrice && Date.now() - _dvpnPriceAt < 300_000) return _dvpnPrice;
649
+ try {
650
+ const res = await axios.get('https://api.coingecko.com/api/v3/simple/price?ids=sentinel&vs_currencies=usd', { timeout: 10000 });
651
+ _dvpnPrice = res.data?.sentinel?.usd || null;
652
+ _dvpnPriceAt = Date.now();
653
+ } catch { /* keep old value */ }
654
+ return _dvpnPrice;
655
+ }
656
+
657
+ // ─── Protobuf Helpers for FeeGrant & Authz ──────────────────────────────────
658
+ // Uses the same manual protobuf encoding as Sentinel types — no codegen needed.
659
+
660
+ function encodeCoin(denom, amount) {
661
+ return Buffer.concat([protoString(1, denom), protoString(2, String(amount))]);
662
+ }
663
+
664
+ function encodeTimestamp(date) {
665
+ const ms = date.getTime();
666
+ if (Number.isNaN(ms)) throw new ValidationError(ErrorCodes.VALIDATION_FAILED, 'encodeTimestamp(): invalid date', { date });
667
+ const seconds = BigInt(Math.floor(ms / 1000));
668
+ return Buffer.concat([protoInt64(1, seconds)]);
669
+ }
670
+
671
+ function encodeAny(typeUrl, valueBytes) {
672
+ return Buffer.concat([
673
+ protoString(1, typeUrl),
674
+ protoEmbedded(2, valueBytes),
675
+ ]);
676
+ }
677
+
678
+ function encodeBasicAllowance(spendLimit, expiration) {
679
+ const parts = [];
680
+ if (spendLimit != null && spendLimit !== false) {
681
+ const coins = Array.isArray(spendLimit) ? spendLimit : [{ denom: 'udvpn', amount: String(spendLimit) }];
682
+ for (const coin of coins) {
683
+ parts.push(protoEmbedded(1, encodeCoin(coin.denom || 'udvpn', coin.amount)));
684
+ }
685
+ }
686
+ if (expiration) {
687
+ parts.push(protoEmbedded(2, encodeTimestamp(expiration instanceof Date ? expiration : new Date(expiration))));
688
+ }
689
+ return Buffer.concat(parts);
690
+ }
691
+
692
+ function encodeAllowedMsgAllowance(innerTypeUrl, innerBytes, allowedMessages) {
693
+ const parts = [protoEmbedded(1, encodeAny(innerTypeUrl, innerBytes))];
694
+ for (const msg of allowedMessages) {
695
+ parts.push(protoString(2, msg));
696
+ }
697
+ return Buffer.concat(parts);
698
+ }
699
+
700
+ function encodeGenericAuthorization(msgTypeUrl) {
701
+ return protoString(1, msgTypeUrl);
702
+ }
703
+
704
+ function encodeGrant(authTypeUrl, authBytes, expiration) {
705
+ const parts = [protoEmbedded(1, encodeAny(authTypeUrl, authBytes))];
706
+ if (expiration) {
707
+ parts.push(protoEmbedded(2, encodeTimestamp(expiration instanceof Date ? expiration : new Date(expiration))));
708
+ }
709
+ return Buffer.concat(parts);
710
+ }
711
+
712
+ // ─── FeeGrant (cosmos.feegrant.v1beta1) ─────────────────────────────────────
713
+ // Gas-free UX: granter pays fees for grantee's transactions.
714
+ //
715
+ // Usage:
716
+ // const msg = buildFeeGrantMsg(serviceAddr, userAddr, { spendLimit: 5000000 });
717
+ // await broadcast(client, serviceAddr, [msg]);
718
+ // // Now userAddr can broadcast without P2P for gas
719
+ // await broadcastWithFeeGrant(client, userAddr, [connectMsg], serviceAddr);
720
+
721
+ /**
722
+ * Build a MsgGrantAllowance message.
723
+ * @param {string} granter - Address paying fees (sent1...)
724
+ * @param {string} grantee - Address receiving fee grant (sent1...)
725
+ * @param {object} opts
726
+ * @param {number|Array} opts.spendLimit - Max spend in udvpn (number) or [{denom, amount}]
727
+ * @param {Date|string} opts.expiration - Optional expiry date
728
+ * @param {string[]} opts.allowedMessages - Optional: restrict to specific msg types (uses AllowedMsgAllowance)
729
+ */
730
+ export function buildFeeGrantMsg(granter, grantee, opts = {}) {
731
+ const { spendLimit, expiration, allowedMessages } = opts;
732
+ const basicBytes = encodeBasicAllowance(spendLimit, expiration);
733
+
734
+ let allowanceTypeUrl, allowanceBytes;
735
+ if (allowedMessages?.length) {
736
+ allowanceTypeUrl = '/cosmos.feegrant.v1beta1.AllowedMsgAllowance';
737
+ allowanceBytes = encodeAllowedMsgAllowance(
738
+ '/cosmos.feegrant.v1beta1.BasicAllowance', basicBytes, allowedMessages
739
+ );
740
+ } else {
741
+ allowanceTypeUrl = '/cosmos.feegrant.v1beta1.BasicAllowance';
742
+ allowanceBytes = basicBytes;
743
+ }
744
+
745
+ // MsgGrantAllowance: field 1=granter, field 2=grantee, field 3=allowance(Any)
746
+ return {
747
+ typeUrl: '/cosmos.feegrant.v1beta1.MsgGrantAllowance',
748
+ value: { granter, grantee, allowance: { typeUrl: allowanceTypeUrl, value: Uint8Array.from(allowanceBytes) } },
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Build a MsgRevokeAllowance message.
754
+ */
755
+ export function buildRevokeFeeGrantMsg(granter, grantee) {
756
+ return {
757
+ typeUrl: '/cosmos.feegrant.v1beta1.MsgRevokeAllowance',
758
+ value: { granter, grantee },
759
+ };
760
+ }
761
+
762
+ /**
763
+ * Query fee grants given to a grantee.
764
+ * @returns {Promise<Array>} Array of allowance objects
765
+ */
766
+ export async function queryFeeGrants(lcdUrl, grantee) {
767
+ const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`);
768
+ return data.allowances || [];
769
+ }
770
+
771
+ /**
772
+ * Query a specific fee grant between granter and grantee.
773
+ * @returns {Promise<object|null>} Allowance object or null
774
+ */
775
+ export async function queryFeeGrant(lcdUrl, granter, grantee) {
776
+ try {
777
+ const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
778
+ return data.allowance || null;
779
+ } catch { return null; } // 404 = no grant
780
+ }
781
+
782
+ /**
783
+ * Broadcast a TX with fee paid by a granter (fee grant).
784
+ * The grantee signs; the granter pays gas via their fee allowance.
785
+ * @param {SigningStargateClient} client - Client with grantee's wallet
786
+ * @param {string} signerAddress - Grantee address (sent1...)
787
+ * @param {Array} msgs - Messages to broadcast
788
+ * @param {string} granterAddress - Fee granter address (sent1...)
789
+ * @param {string} memo - Optional memo
790
+ */
791
+ export async function broadcastWithFeeGrant(client, signerAddress, msgs, granterAddress, memo = '') {
792
+ const gasEstimate = await client.simulate(signerAddress, msgs, memo);
793
+ const gasLimit = Math.ceil(gasEstimate * 1.3);
794
+ const fee = {
795
+ amount: [{ denom: 'udvpn', amount: String(Math.ceil(gasLimit * 0.2)) }],
796
+ gas: String(gasLimit),
797
+ granter: granterAddress,
798
+ };
799
+ return client.signAndBroadcast(signerAddress, msgs, fee, memo);
800
+ }
801
+
802
+ // ─── Authz (cosmos.authz.v1beta1) ──────────────────────────────────────────
803
+ // Authorization grants: granter allows grantee to execute specific messages.
804
+ //
805
+ // Usage (server-side subscription management):
806
+ // // User grants server permission to start sessions on their behalf
807
+ // const msg = buildAuthzGrantMsg(userAddr, serverAddr, MSG_TYPES.PLAN_START_SESSION);
808
+ // await broadcast(client, userAddr, [msg]);
809
+ // // Server can now start sessions for the user
810
+ // const innerMsg = { typeUrl: MSG_TYPES.PLAN_START_SESSION, value: { from: userAddr, ... } };
811
+ // const execMsg = buildAuthzExecMsg(serverAddr, encodeForExec([innerMsg]));
812
+ // await broadcast(serverClient, serverAddr, [execMsg]);
813
+
814
+ /**
815
+ * Build a MsgGrant (authz) for a specific message type.
816
+ * @param {string} granter - Address granting permission (sent1...)
817
+ * @param {string} grantee - Address receiving permission (sent1...)
818
+ * @param {string} msgTypeUrl - Message type URL to authorize (e.g. MSG_TYPES.START_SESSION)
819
+ * @param {Date|string} expiration - Optional expiry date (default: no expiry)
820
+ */
821
+ export function buildAuthzGrantMsg(granter, grantee, msgTypeUrl, expiration) {
822
+ const authBytes = encodeGenericAuthorization(msgTypeUrl);
823
+
824
+ return {
825
+ typeUrl: '/cosmos.authz.v1beta1.MsgGrant',
826
+ value: {
827
+ granter,
828
+ grantee,
829
+ grant: {
830
+ authorization: {
831
+ typeUrl: '/cosmos.authz.v1beta1.GenericAuthorization',
832
+ value: Uint8Array.from(authBytes),
833
+ },
834
+ expiration: expiration
835
+ ? { seconds: BigInt(Math.floor((expiration instanceof Date ? expiration : new Date(expiration)).getTime() / 1000)), nanos: 0 }
836
+ : undefined,
837
+ },
838
+ },
839
+ };
840
+ }
841
+
842
+ /**
843
+ * Build a MsgRevoke (authz) to remove a specific grant.
844
+ */
845
+ export function buildAuthzRevokeMsg(granter, grantee, msgTypeUrl) {
846
+ return {
847
+ typeUrl: '/cosmos.authz.v1beta1.MsgRevoke',
848
+ value: { granter, grantee, msgTypeUrl },
849
+ };
850
+ }
851
+
852
+ /**
853
+ * Build a MsgExec (authz) to execute messages on behalf of a granter.
854
+ * @param {string} grantee - Address executing on behalf of granter
855
+ * @param {Array} encodedMsgs - Pre-encoded messages (use encodeForExec() to prepare)
856
+ */
857
+ export function buildAuthzExecMsg(grantee, encodedMsgs) {
858
+ return {
859
+ typeUrl: '/cosmos.authz.v1beta1.MsgExec',
860
+ value: { grantee, msgs: encodedMsgs },
861
+ };
862
+ }
863
+
864
+ /**
865
+ * Encode SDK message objects to the Any format required by MsgExec.
866
+ * @param {Array<{typeUrl: string, value: object}>} msgs - Standard SDK messages
867
+ * @returns {Array<{typeUrl: string, value: Uint8Array}>} Encoded messages for MsgExec
868
+ */
869
+ export function encodeForExec(msgs) {
870
+ const reg = buildRegistry();
871
+ return msgs.map(msg => {
872
+ const type = reg.lookupType(msg.typeUrl);
873
+ if (!type) throw new Error(`Unknown message type: ${msg.typeUrl}. Ensure it is registered in buildRegistry().`);
874
+ return {
875
+ typeUrl: msg.typeUrl,
876
+ value: type.encode(type.fromPartial(msg.value)).finish(),
877
+ };
878
+ });
879
+ }
880
+
881
+ /**
882
+ * Query authz grants between granter and grantee.
883
+ * @returns {Promise<Array>} Array of grant objects
884
+ */
885
+ export async function queryAuthzGrants(lcdUrl, granter, grantee) {
886
+ const data = await lcd(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`);
887
+ return data.grants || [];
888
+ }
889
+
890
+ // Re-export extractSessionId for convenience (from protocol module)
891
+ export { extractSessionId };