blue-js-sdk 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,9 +34,12 @@ import {
34
34
  getBalance as sdkGetBalance,
35
35
  tryWithFallback,
36
36
  RPC_ENDPOINTS,
37
+ LCD_ENDPOINTS,
38
+ queryFeeGrant,
37
39
  // v1.5.0: RPC queries (protobuf, ~10x faster than LCD for balance checks)
38
40
  createRpcQueryClientWithFallback,
39
41
  rpcQueryBalance,
42
+ rpcQueryFeeGrant,
40
43
  // v1.5.0: Typed event parsers (replaces string matching for session ID extraction)
41
44
  extractSessionIdTyped,
42
45
  NodeEventCreateSession,
@@ -193,6 +196,18 @@ function humanError(err) {
193
196
  message: 'Connection was cancelled.',
194
197
  nextAction: 'none',
195
198
  },
199
+ FEE_GRANT_NOT_FOUND: {
200
+ message: 'No fee grant from operator to agent. Operator must provision a grant first.',
201
+ nextAction: 'request_fee_grant',
202
+ },
203
+ FEE_GRANT_EXPIRED: {
204
+ message: 'Fee grant has expired. Operator must renew the grant.',
205
+ nextAction: 'request_fee_grant_renewal',
206
+ },
207
+ FEE_GRANT_EXHAUSTED: {
208
+ message: 'Fee grant spend limit exhausted. Operator must top up the grant.',
209
+ nextAction: 'request_fee_grant_renewal',
210
+ },
196
211
  };
197
212
 
198
213
  const entry = messages[code];
@@ -364,6 +379,101 @@ export async function connect(opts = {}) {
364
379
  }
365
380
  timings.balance = Date.now() - t0;
366
381
 
382
+ // ── STEP 3.5: Fee Grant Validity Check (when feeGranter is set) ───────────
383
+ // Verify the fee grant exists on-chain and hasn't expired before attempting
384
+ // a connection that would fail at broadcast time.
385
+
386
+ if (opts.feeGranter) {
387
+ try {
388
+ // RPC first (protobuf, ~10x faster), LCD fallback
389
+ let grant = null;
390
+ try {
391
+ const rpcClient = await createRpcQueryClientWithFallback();
392
+ grant = await rpcQueryFeeGrant(rpcClient, opts.feeGranter, walletAddress);
393
+ } catch {
394
+ // RPC failed — fall back to LCD with failover
395
+ const lcdResult = await tryWithFallback(
396
+ LCD_ENDPOINTS,
397
+ async (endpoint) => {
398
+ const url = endpoint?.url || endpoint;
399
+ return queryFeeGrant(url, opts.feeGranter, walletAddress);
400
+ },
401
+ 'fee grant pre-check (LCD fallback)',
402
+ );
403
+ grant = lcdResult.result;
404
+ }
405
+
406
+ if (!grant) {
407
+ const err = new Error(`No fee grant found from ${opts.feeGranter} to ${walletAddress}. Operator must create a fee grant before agent can connect with 0 P2P.`);
408
+ err.code = 'FEE_GRANT_NOT_FOUND';
409
+ err.nextAction = 'request_fee_grant';
410
+ err.details = { granter: opts.feeGranter, grantee: walletAddress };
411
+ throw err;
412
+ }
413
+
414
+ // Unwrap grant structure: AllowedMsgAllowance > BasicAllowance
415
+ // Chain returns: { allowance: { "@type": "AllowedMsg...", allowance: { "@type": "Basic...", spend_limit, expiration }, allowed_messages: [...] } }
416
+ const outerAllowance = grant.allowance || grant;
417
+ const isAllowedMsg = outerAllowance['@type']?.includes('AllowedMsgAllowance');
418
+ const inner = isAllowedMsg ? (outerAllowance.allowance || outerAllowance) : outerAllowance;
419
+ const expiration = inner.expiration || outerAllowance.expiration;
420
+
421
+ // Check expiration
422
+ if (expiration) {
423
+ const expiresAt = new Date(expiration);
424
+ const now = new Date();
425
+ if (expiresAt <= now) {
426
+ const err = new Error(`Fee grant from ${opts.feeGranter} expired at ${expiresAt.toISOString()}. Operator must renew the fee grant.`);
427
+ err.code = 'FEE_GRANT_EXPIRED';
428
+ err.nextAction = 'request_fee_grant_renewal';
429
+ err.details = { granter: opts.feeGranter, grantee: walletAddress, expiredAt: expiresAt.toISOString() };
430
+ throw err;
431
+ }
432
+
433
+ const hoursLeft = (expiresAt - now) / 3600000;
434
+ if (hoursLeft < 1) {
435
+ log(3, totalSteps, 'FEE_GRANT', `Warning: Fee grant expires in ${Math.round(hoursLeft * 60)} minutes`);
436
+ } else {
437
+ log(3, totalSteps, 'FEE_GRANT', `Fee grant valid, expires ${expiresAt.toISOString()} (${Math.round(hoursLeft)}h remaining)`);
438
+ }
439
+ } else {
440
+ log(3, totalSteps, 'FEE_GRANT', 'Fee grant valid (no expiration)');
441
+ }
442
+
443
+ // Check spend_limit — if set, ensure there's enough remaining for at least one TX
444
+ const spendLimit = inner.spend_limit;
445
+ if (spendLimit && Array.isArray(spendLimit)) {
446
+ const udvpnLimit = spendLimit.find(c => c.denom === 'udvpn');
447
+ if (udvpnLimit) {
448
+ const remaining = parseInt(udvpnLimit.amount, 10) || 0;
449
+ if (remaining < 20000) { // 20,000 udvpn = minimum for one session TX
450
+ const err = new Error(`Fee grant from ${opts.feeGranter} has insufficient spend limit: ${remaining} udvpn remaining (need 20,000 for session TX). Operator must top up the grant.`);
451
+ err.code = 'FEE_GRANT_EXHAUSTED';
452
+ err.nextAction = 'request_fee_grant_renewal';
453
+ err.details = { granter: opts.feeGranter, grantee: walletAddress, remainingUdvpn: remaining };
454
+ throw err;
455
+ }
456
+ log(3, totalSteps, 'FEE_GRANT', `Spend limit: ${remaining} udvpn remaining`);
457
+ }
458
+ }
459
+
460
+ // Check allowed_messages — verify it includes the messages we need
461
+ const allowedMessages = isAllowedMsg ? (outerAllowance.allowed_messages || []) : [];
462
+ if (allowedMessages.length > 0) {
463
+ const needsStart = allowedMessages.some(m =>
464
+ m.includes('MsgStartSession') || m.includes('MsgStartSessionRequest'),
465
+ );
466
+ if (!needsStart) {
467
+ log(3, totalSteps, 'FEE_GRANT', `Warning: allowed_messages doesn't include MsgStartSession — TX may fail`);
468
+ }
469
+ }
470
+ } catch (err) {
471
+ if (err.code === 'FEE_GRANT_NOT_FOUND' || err.code === 'FEE_GRANT_EXPIRED' || err.code === 'FEE_GRANT_EXHAUSTED') throw err;
472
+ // Non-critical — LCD query failed but fee grant may still work at broadcast time
473
+ log(3, totalSteps, 'FEE_GRANT', `Could not verify fee grant (${err.message}) — proceeding anyway`);
474
+ }
475
+ }
476
+
367
477
  // ── STEP 4/7: Node Selection ──────────────────────────────────────────────
368
478
 
369
479
  t0 = Date.now();
@@ -584,11 +694,13 @@ export async function connect(opts = {}) {
584
694
  } else if (resolvedNodeAddress) {
585
695
  // Direct connection — either user specified nodeAddress or country discovery found one
586
696
  sdkOpts.nodeAddress = resolvedNodeAddress;
697
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
587
698
  result = await connectDirect(sdkOpts);
588
699
  } else {
589
700
  // No country filter or country discovery found nothing — auto-select globally
590
701
  // Use higher maxAttempts to search more nodes
591
702
  if (!sdkOpts.maxAttempts) sdkOpts.maxAttempts = 5;
703
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
592
704
  result = await connectAuto(sdkOpts);
593
705
  }
594
706
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import {
8
8
  estimateSessionCost,
9
- estimateSessionPrice,
9
+ getNodePrices,
10
10
  formatP2P,
11
11
  PRICING_REFERENCE,
12
12
  DENOM,
@@ -81,11 +81,12 @@ export async function estimateCost(opts = {}) {
81
81
  const gb = opts.gigabytes || 1;
82
82
  const gasCost = 40000; // ~0.04 P2P per TX
83
83
 
84
- // If specific node given, get exact price
84
+ // If specific node given, get exact price via RPC-first node query
85
85
  if (opts.nodeAddress) {
86
86
  try {
87
- const price = await estimateSessionPrice(opts.nodeAddress, gb);
88
- const total = price.udvpn || price.amount || 0;
87
+ const prices = await getNodePrices(opts.nodeAddress);
88
+ const perGbUdvpn = prices.gigabyte.udvpn || 0;
89
+ const total = perGbUdvpn * gb;
89
90
  const grandTotal = total + gasCost;
90
91
 
91
92
  const result = {
package/batch.js CHANGED
@@ -29,10 +29,9 @@ import { sleep } from './defaults.js';
29
29
  import {
30
30
  broadcast,
31
31
  extractAllSessionIds,
32
- findExistingSession,
33
- lcdPaginatedSafe,
34
32
  MSG_TYPES,
35
33
  } from './cosmjs-setup.js';
34
+ import { findExistingSession, querySessions } from './chain/queries.js';
36
35
 
37
36
  // ─── Constants ───────────────────────────────────────────────────────────────
38
37
 
@@ -257,18 +256,15 @@ export async function waitForBatchSessions(nodeAddrs, walletAddr, lcdUrl, option
257
256
  while (pending.size > 0 && Date.now() < deadline) {
258
257
  await sleep(pollIntervalMs);
259
258
  try {
260
- const result = await lcdPaginatedSafe(
261
- baseLcd,
262
- `/sentinel/session/v3/sessions?address=${walletAddr}&status=1`,
263
- 'sessions',
264
- );
259
+ // RPC-first via chain/queries.js — returns flattened sessions
260
+ const result = await querySessions(walletAddr, baseLcd, { status: '1' });
265
261
  for (const s of (result.items || [])) {
266
262
  const bs = s.base_session || s;
267
263
  const n = bs.node_address || bs.node;
268
264
  if (pending.has(n)) pending.delete(n);
269
265
  }
270
266
  } catch {
271
- // Transient LCD error — will retry on next poll
267
+ // Transient RPC/LCD error — will retry on next poll
272
268
  }
273
269
  }
274
270
 
@@ -16,6 +16,12 @@ import { ValidationError, ErrorCodes } from '../errors.js';
16
16
  import { lcd, lcdPaginatedSafe, lcdQueryAll } from './lcd.js';
17
17
  import { isSameKey } from './wallet.js';
18
18
  import { queryPlanSubscribers } from './queries.js';
19
+ import {
20
+ createRpcQueryClientWithFallback,
21
+ rpcQueryFeeGrant as _rpcQueryFeeGrant,
22
+ rpcQueryFeeGrants as _rpcQueryFeeGrants,
23
+ rpcQueryFeeGrantsIssued as _rpcQueryFeeGrantsIssued,
24
+ } from './rpc.js';
19
25
 
20
26
  // ─── Protobuf Helpers for FeeGrant ──────────────────────────────────────────
21
27
  // Uses the same manual protobuf encoding as Sentinel types — no codegen needed.
@@ -60,6 +66,21 @@ function encodeAny(typeUrl, valueBytes) {
60
66
  ]);
61
67
  }
62
68
 
69
+ // ─── RPC Client Helper ─────────────────────────────────────────────────────
70
+
71
+ let _rpcClient = null;
72
+ let _rpcClientPromise = null;
73
+
74
+ async function getRpcClient() {
75
+ if (_rpcClient) return _rpcClient;
76
+ if (_rpcClientPromise) return _rpcClientPromise;
77
+ _rpcClientPromise = createRpcQueryClientWithFallback()
78
+ .then(client => { _rpcClient = client; return client; })
79
+ .catch(() => { _rpcClient = null; return null; })
80
+ .finally(() => { _rpcClientPromise = null; });
81
+ return _rpcClientPromise;
82
+ }
83
+
63
84
  // ─── FeeGrant (cosmos.feegrant.v1beta1) ─────────────────────────────────────
64
85
 
65
86
  /**
@@ -105,29 +126,59 @@ export function buildRevokeFeeGrantMsg(granter, grantee) {
105
126
 
106
127
  /**
107
128
  * Query fee grants given to a grantee.
129
+ * RPC-first with LCD fallback.
108
130
  * @returns {Promise<Array>} Array of allowance objects
109
131
  */
110
132
  export async function queryFeeGrants(lcdUrl, grantee) {
133
+ // RPC-first
134
+ try {
135
+ const rpc = await getRpcClient();
136
+ if (rpc) {
137
+ return await _rpcQueryFeeGrants(rpc, grantee);
138
+ }
139
+ } catch { /* fall through to LCD */ }
140
+
141
+ // LCD fallback
111
142
  const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/allowances/${grantee}`, 'allowances');
112
143
  return items;
113
144
  }
114
145
 
115
146
  /**
116
147
  * Query fee grants issued BY an address (where addr is the granter).
148
+ * RPC-first with LCD fallback.
117
149
  * @param {string} lcdUrl
118
150
  * @param {string} granter - Address that issued the grants
119
151
  * @returns {Promise<Array>}
120
152
  */
121
153
  export async function queryFeeGrantsIssued(lcdUrl, granter) {
154
+ // RPC-first
155
+ try {
156
+ const rpc = await getRpcClient();
157
+ if (rpc) {
158
+ return await _rpcQueryFeeGrantsIssued(rpc, granter);
159
+ }
160
+ } catch { /* fall through to LCD */ }
161
+
162
+ // LCD fallback
122
163
  const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/feegrant/v1beta1/issued/${granter}`, 'allowances');
123
164
  return items;
124
165
  }
125
166
 
126
167
  /**
127
168
  * Query a specific fee grant between granter and grantee.
169
+ * RPC-first with LCD fallback.
128
170
  * @returns {Promise<object|null>} Allowance object or null
129
171
  */
130
172
  export async function queryFeeGrant(lcdUrl, granter, grantee) {
173
+ // RPC-first
174
+ try {
175
+ const rpc = await getRpcClient();
176
+ if (rpc) {
177
+ return await _rpcQueryFeeGrant(rpc, granter, grantee);
178
+ }
179
+ } catch { /* fall through to LCD */ }
180
+
181
+ // LCD fallback
131
182
  try {
132
183
  const data = await lcd(lcdUrl, `/cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`);
133
184
  return data.allowance || null;