blue-js-sdk 2.1.0 → 2.2.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.
@@ -19,6 +19,8 @@
19
19
  import {
20
20
  connectAuto,
21
21
  connectDirect,
22
+ connectViaSubscription,
23
+ connectViaPlan,
22
24
  disconnect as sdkDisconnect,
23
25
  isConnected,
24
26
  getStatus,
@@ -32,9 +34,12 @@ import {
32
34
  getBalance as sdkGetBalance,
33
35
  tryWithFallback,
34
36
  RPC_ENDPOINTS,
37
+ LCD_ENDPOINTS,
38
+ queryFeeGrant,
35
39
  // v1.5.0: RPC queries (protobuf, ~10x faster than LCD for balance checks)
36
40
  createRpcQueryClientWithFallback,
37
41
  rpcQueryBalance,
42
+ rpcQueryFeeGrant,
38
43
  // v1.5.0: Typed event parsers (replaces string matching for session ID extraction)
39
44
  extractSessionIdTyped,
40
45
  NodeEventCreateSession,
@@ -191,6 +196,18 @@ function humanError(err) {
191
196
  message: 'Connection was cancelled.',
192
197
  nextAction: 'none',
193
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
+ },
194
211
  };
195
212
 
196
213
  const entry = messages[code];
@@ -245,6 +262,14 @@ async function preValidateBalance(mnemonic) {
245
262
  /**
246
263
  * Connect to Sentinel dVPN. The ONE function an AI agent needs.
247
264
  *
265
+ * Three connection modes:
266
+ * 1. Direct payment — agent pays per-session from own wallet (default)
267
+ * 2. Subscription — operator provisioned a subscription for this agent
268
+ * 3. Plan — subscribe to plan + start session (optionally fee-granted)
269
+ *
270
+ * For modes 2 & 3, set opts.feeGranter to the operator's address — the
271
+ * agent can have 0 P2P balance and the operator covers gas.
272
+ *
248
273
  * Every step is logged with numbered phases (STEP 1/7 through STEP 7/7)
249
274
  * so an autonomous agent can track progress and diagnose failures.
250
275
  *
@@ -254,6 +279,9 @@ async function preValidateBalance(mnemonic) {
254
279
  * @param {string} [opts.nodeAddress] - Specific node (sentnode1...). Skips auto-pick.
255
280
  * @param {string} [opts.dns] - DNS preset: 'google', 'cloudflare', 'hns'
256
281
  * @param {string} [opts.protocol] - Preferred protocol: 'wireguard' or 'v2ray'
282
+ * @param {string|number} [opts.subscriptionId] - Connect via existing subscription (operator-provisioned)
283
+ * @param {string|number} [opts.planId] - Connect via plan (subscribes + starts session)
284
+ * @param {string} [opts.feeGranter] - Operator address that pays gas (sent1...). Skips balance check.
257
285
  * @param {function} [opts.onProgress] - Progress callback: (stage, message) => void
258
286
  * @param {number} [opts.timeout] - Connection timeout in ms (default: 120000 — 2 minutes)
259
287
  * @param {boolean} [opts.silent] - If true, suppress step-by-step console output
@@ -338,15 +366,114 @@ export async function connect(opts = {}) {
338
366
  const balCheck = await preValidateBalance(opts.mnemonic);
339
367
  log(3, totalSteps, 'BALANCE', `Balance: ${balCheck.p2p} | Sufficient: ${balCheck.sufficient}`);
340
368
 
341
- if (!balCheck.sufficient && !opts.dryRun) {
369
+ // Skip balance gate when fee granter is set — agent may have 0 P2P, operator pays gas
370
+ if (!balCheck.sufficient && !opts.dryRun && !opts.feeGranter) {
342
371
  const err = new Error(`Insufficient balance: ${balCheck.p2p}. Need at least ${formatP2P(MIN_BALANCE_UDVPN)}. Fund address: ${walletAddress}`);
343
372
  err.code = 'INSUFFICIENT_BALANCE';
344
373
  err.nextAction = 'fund_wallet';
345
374
  err.details = { address: walletAddress, balance: balCheck.p2p, minimum: formatP2P(MIN_BALANCE_UDVPN) };
346
375
  throw err;
347
376
  }
377
+ if (opts.feeGranter && !balCheck.sufficient) {
378
+ log(3, totalSteps, 'BALANCE', `Balance below minimum but fee granter ${opts.feeGranter} covers gas`);
379
+ }
348
380
  timings.balance = Date.now() - t0;
349
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
+
350
477
  // ── STEP 4/7: Node Selection ──────────────────────────────────────────────
351
478
 
352
479
  t0 = Date.now();
@@ -549,14 +676,31 @@ export async function connect(opts = {}) {
549
676
  try {
550
677
  let result;
551
678
 
552
- if (resolvedNodeAddress) {
679
+ // ── Connection mode: subscription > plan > direct > auto ──────────────
680
+ if (opts.subscriptionId) {
681
+ // Subscription mode — operator already provisioned a subscription for this agent
682
+ log(5, totalSteps, 'SESSION', `Connecting via subscription ${opts.subscriptionId}${opts.feeGranter ? ' (fee granted)' : ''}...`);
683
+ sdkOpts.subscriptionId = opts.subscriptionId;
684
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
685
+ if (resolvedNodeAddress) sdkOpts.nodeAddress = resolvedNodeAddress;
686
+ result = await connectViaSubscription(sdkOpts);
687
+ } else if (opts.planId) {
688
+ // Plan mode — subscribe to plan + start session (optionally fee-granted)
689
+ log(5, totalSteps, 'SESSION', `Connecting via plan ${opts.planId}${opts.feeGranter ? ' (fee granted)' : ''}...`);
690
+ sdkOpts.planId = opts.planId;
691
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
692
+ if (resolvedNodeAddress) sdkOpts.nodeAddress = resolvedNodeAddress;
693
+ result = await connectViaPlan(sdkOpts);
694
+ } else if (resolvedNodeAddress) {
553
695
  // Direct connection — either user specified nodeAddress or country discovery found one
554
696
  sdkOpts.nodeAddress = resolvedNodeAddress;
697
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
555
698
  result = await connectDirect(sdkOpts);
556
699
  } else {
557
700
  // No country filter or country discovery found nothing — auto-select globally
558
701
  // Use higher maxAttempts to search more nodes
559
702
  if (!sdkOpts.maxAttempts) sdkOpts.maxAttempts = 5;
703
+ if (opts.feeGranter) sdkOpts.feeGranter = opts.feeGranter;
560
704
  result = await connectAuto(sdkOpts);
561
705
  }
562
706
 
package/ai-path/index.js CHANGED
@@ -65,10 +65,15 @@ export {
65
65
  subscribeToPlan,
66
66
  hasActiveSubscription,
67
67
  querySubscriptions,
68
+ querySubscriptionAllocations,
68
69
  queryPlanNodes,
69
70
  queryFeeGrants,
70
71
  buildFeeGrantMsg,
71
72
  broadcastWithFeeGrant,
73
+ // v2.1.0: Subscription sharing + onboarding (operator provisions agent access)
74
+ shareSubscription,
75
+ shareSubscriptionWithFeeGrant,
76
+ onboardPlanUser,
72
77
  rpcQueryNodesForPlan,
73
78
  rpcQuerySubscriptionsForAccount,
74
79
  } from '../index.js';
@@ -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 = {