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.
- package/CHANGELOG.md +446 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/ai-path/ADMIN-ELEVATION.md +116 -0
- package/ai-path/AI-MANIFESTO.md +185 -0
- package/ai-path/BREAKING.md +74 -0
- package/ai-path/CHECKLIST.md +619 -0
- package/ai-path/CONNECTION-STEPS.md +724 -0
- package/ai-path/DECISION-TREE.md +378 -0
- package/ai-path/DEPENDENCIES.md +459 -0
- package/ai-path/E2E-FLOW.md +1555 -0
- package/ai-path/FAILURES.md +403 -0
- package/ai-path/GUIDE.md +1217 -0
- package/ai-path/README.md +558 -0
- package/ai-path/SPLIT-TUNNEL.md +266 -0
- package/ai-path/cli.js +535 -0
- package/ai-path/connect.js +884 -0
- package/ai-path/discover.js +178 -0
- package/ai-path/environment.js +266 -0
- package/ai-path/errors.js +86 -0
- package/ai-path/examples/autonomous-agent.mjs +220 -0
- package/ai-path/examples/multi-region.mjs +174 -0
- package/ai-path/examples/one-shot.mjs +31 -0
- package/ai-path/index.js +60 -0
- package/ai-path/pricing.js +136 -0
- package/ai-path/recommend.js +413 -0
- package/ai-path/run-admin.vbs +25 -0
- package/ai-path/setup.js +291 -0
- package/ai-path/wallet.js +137 -0
- package/app-helpers.js +363 -0
- package/app-settings.js +95 -0
- package/app-types.js +267 -0
- package/audit.js +847 -0
- package/batch.js +293 -0
- package/bin/setup.js +376 -0
- package/chain/authz.js +109 -0
- package/chain/broadcast.js +472 -0
- package/chain/client.js +160 -0
- package/chain/fee-grants.js +305 -0
- package/chain/index.js +891 -0
- package/chain/lcd.js +313 -0
- package/chain/queries.js +547 -0
- package/chain/rpc.js +408 -0
- package/chain/wallet.js +141 -0
- package/cli/config.js +143 -0
- package/cli/index.js +463 -0
- package/cli/output.js +182 -0
- package/cli.js +491 -0
- package/client/index.js +251 -0
- package/client.js +271 -0
- package/config/index.js +255 -0
- package/connection/connect.js +849 -0
- package/connection/disconnect.js +180 -0
- package/connection/discovery.js +321 -0
- package/connection/index.js +76 -0
- package/connection/proxy.js +148 -0
- package/connection/resilience.js +428 -0
- package/connection/security.js +232 -0
- package/connection/state.js +369 -0
- package/connection/tunnel.js +691 -0
- package/consumer.js +132 -0
- package/cosmjs-setup.js +1884 -0
- package/defaults.js +366 -0
- package/disk-cache.js +107 -0
- package/dist/client.d.ts +108 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +400 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/errors/index.js +112 -0
- package/errors.js +218 -0
- package/examples/README.md +64 -0
- package/examples/connect-direct.mjs +106 -0
- package/examples/connect-plan.mjs +125 -0
- package/examples/error-handling.mjs +109 -0
- package/examples/query-nodes.mjs +94 -0
- package/examples/wallet-basics.mjs +61 -0
- package/generated/amino/amino.ts +9 -0
- package/generated/cosmos/base/v1beta1/coin.ts +365 -0
- package/generated/cosmos_proto/cosmos.ts +323 -0
- package/generated/gogoproto/gogo.ts +9 -0
- package/generated/google/protobuf/descriptor.ts +7601 -0
- package/generated/google/protobuf/duration.ts +208 -0
- package/generated/google/protobuf/timestamp.ts +238 -0
- package/generated/sentinel/lease/v1/events.ts +924 -0
- package/generated/sentinel/lease/v1/lease.ts +292 -0
- package/generated/sentinel/lease/v1/msg.ts +949 -0
- package/generated/sentinel/lease/v1/params.ts +164 -0
- package/generated/sentinel/node/v3/events.ts +881 -0
- package/generated/sentinel/node/v3/msg.ts +1002 -0
- package/generated/sentinel/node/v3/node.ts +263 -0
- package/generated/sentinel/node/v3/params.ts +183 -0
- package/generated/sentinel/plan/v3/events.ts +675 -0
- package/generated/sentinel/plan/v3/msg.ts +1191 -0
- package/generated/sentinel/plan/v3/plan.ts +283 -0
- package/generated/sentinel/provider/v2/events.ts +171 -0
- package/generated/sentinel/provider/v2/msg.ts +480 -0
- package/generated/sentinel/provider/v2/params.ts +131 -0
- package/generated/sentinel/provider/v2/provider.ts +246 -0
- package/generated/sentinel/session/v3/events.ts +480 -0
- package/generated/sentinel/session/v3/msg.ts +616 -0
- package/generated/sentinel/session/v3/params.ts +260 -0
- package/generated/sentinel/session/v3/proof.ts +180 -0
- package/generated/sentinel/session/v3/session.ts +384 -0
- package/generated/sentinel/subscription/v3/events.ts +1181 -0
- package/generated/sentinel/subscription/v3/msg.ts +1305 -0
- package/generated/sentinel/subscription/v3/params.ts +167 -0
- package/generated/sentinel/subscription/v3/subscription.ts +315 -0
- package/generated/sentinel/types/v1/bandwidth.ts +124 -0
- package/generated/sentinel/types/v1/price.ts +149 -0
- package/generated/sentinel/types/v1/renewal.ts +87 -0
- package/generated/sentinel/types/v1/status.ts +54 -0
- package/generated/typeRegistry.ts +27 -0
- package/index.js +486 -0
- package/node-connect.js +3015 -0
- package/operator.js +134 -0
- package/package.json +113 -0
- package/plan-operations.js +199 -0
- package/preflight.js +352 -0
- package/pricing/index.js +262 -0
- package/proto/amino/amino.proto +84 -0
- package/proto/cosmos/base/v1beta1/coin.proto +61 -0
- package/proto/cosmos_proto/cosmos.proto +112 -0
- package/proto/gogoproto/gogo.proto +145 -0
- package/proto/google/api/annotations.proto +31 -0
- package/proto/google/api/http.proto +370 -0
- package/proto/google/protobuf/any.proto +106 -0
- package/proto/google/protobuf/duration.proto +115 -0
- package/proto/google/protobuf/timestamp.proto +145 -0
- package/proto/sentinel/lease/v1/events.proto +52 -0
- package/proto/sentinel/lease/v1/genesis.proto +15 -0
- package/proto/sentinel/lease/v1/lease.proto +25 -0
- package/proto/sentinel/lease/v1/msg.proto +62 -0
- package/proto/sentinel/lease/v1/params.proto +17 -0
- package/proto/sentinel/node/v3/events.proto +50 -0
- package/proto/sentinel/node/v3/genesis.proto +15 -0
- package/proto/sentinel/node/v3/msg.proto +63 -0
- package/proto/sentinel/node/v3/node.proto +27 -0
- package/proto/sentinel/node/v3/params.proto +21 -0
- package/proto/sentinel/node/v3/querier.proto +63 -0
- package/proto/sentinel/plan/v3/events.proto +41 -0
- package/proto/sentinel/plan/v3/genesis.proto +21 -0
- package/proto/sentinel/plan/v3/msg.proto +83 -0
- package/proto/sentinel/plan/v3/plan.proto +32 -0
- package/proto/sentinel/plan/v3/querier.proto +53 -0
- package/proto/sentinel/provider/v2/events.proto +16 -0
- package/proto/sentinel/provider/v2/genesis.proto +15 -0
- package/proto/sentinel/provider/v2/msg.proto +35 -0
- package/proto/sentinel/provider/v2/params.proto +17 -0
- package/proto/sentinel/provider/v2/provider.proto +24 -0
- package/proto/sentinel/provider/v3/genesis.proto +15 -0
- package/proto/sentinel/provider/v3/params.proto +13 -0
- package/proto/sentinel/session/v3/events.proto +30 -0
- package/proto/sentinel/session/v3/genesis.proto +15 -0
- package/proto/sentinel/session/v3/msg.proto +50 -0
- package/proto/sentinel/session/v3/params.proto +25 -0
- package/proto/sentinel/session/v3/proof.proto +25 -0
- package/proto/sentinel/session/v3/querier.proto +100 -0
- package/proto/sentinel/session/v3/session.proto +50 -0
- package/proto/sentinel/subscription/v2/allocation.proto +21 -0
- package/proto/sentinel/subscription/v2/payout.proto +22 -0
- package/proto/sentinel/subscription/v3/events.proto +65 -0
- package/proto/sentinel/subscription/v3/genesis.proto +17 -0
- package/proto/sentinel/subscription/v3/msg.proto +83 -0
- package/proto/sentinel/subscription/v3/params.proto +21 -0
- package/proto/sentinel/subscription/v3/subscription.proto +33 -0
- package/proto/sentinel/types/v1/bandwidth.proto +19 -0
- package/proto/sentinel/types/v1/price.proto +21 -0
- package/proto/sentinel/types/v1/renewal.proto +21 -0
- package/proto/sentinel/types/v1/status.proto +16 -0
- package/protocol/encoding.js +341 -0
- package/protocol/events.js +361 -0
- package/protocol/handshake.js +297 -0
- package/protocol/index.js +15 -0
- package/protocol/messages.js +346 -0
- package/protocol/plans.js +199 -0
- package/protocol/v2ray.js +268 -0
- package/protocol/v3.js +723 -0
- package/protocol/wireguard.js +125 -0
- package/security/index.js +132 -0
- package/session-manager.js +329 -0
- package/session-tracker.js +80 -0
- package/setup.js +376 -0
- package/speedtest/index.js +528 -0
- package/speedtest.js +567 -0
- package/src/client.ts +502 -0
- package/src/index.ts +20 -0
- package/state/index.js +347 -0
- package/state.js +516 -0
- package/test-all-chain-ops.js +493 -0
- package/test-all-logic.js +199 -0
- package/test-all-msg-types.js +292 -0
- package/test-every-connection.js +208 -0
- package/test-feegrant-connect.js +98 -0
- package/test-logic.js +148 -0
- package/test-mainnet.js +176 -0
- package/test-plan-lifecycle.js +335 -0
- package/tls-trust.js +132 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +34 -0
- package/types/chain.d.ts +746 -0
- package/types/connection.d.ts +425 -0
- package/types/errors.d.ts +174 -0
- package/types/index.d.ts +1380 -0
- package/types/nodes.d.ts +187 -0
- package/types/pricing.d.ts +156 -0
- package/types/protocol.d.ts +332 -0
- package/types/session.d.ts +236 -0
- package/types/settings.d.ts +192 -0
- package/v3protocol.js +1053 -0
- package/wallet/index.js +153 -0
- package/wireguard.js +307 -0
package/chain/authz.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Chain / Authz Module
|
|
3
|
+
*
|
|
4
|
+
* Authorization grants (cosmos.authz.v1beta1): granter allows grantee
|
|
5
|
+
* to execute specific messages on their behalf.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { buildAuthzGrantMsg, buildAuthzExecMsg, encodeForExec } from './chain/authz.js';
|
|
9
|
+
* const msg = buildAuthzGrantMsg(userAddr, serverAddr, MSG_TYPES.PLAN_START_SESSION);
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { protoString, protoEmbedded, protoInt64 } from '../v3protocol.js';
|
|
13
|
+
import { ChainError, ErrorCodes } from '../errors.js';
|
|
14
|
+
import { lcd, lcdPaginatedSafe } from './lcd.js';
|
|
15
|
+
import { buildRegistry } from './client.js';
|
|
16
|
+
|
|
17
|
+
// ─── Protobuf Helpers ───────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function encodeGenericAuthorization(msgTypeUrl) {
|
|
20
|
+
return protoString(1, msgTypeUrl);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Authz (cosmos.authz.v1beta1) ──────────────────────────────────────────
|
|
24
|
+
// Authorization grants: granter allows grantee to execute specific messages.
|
|
25
|
+
//
|
|
26
|
+
// Usage (server-side subscription management):
|
|
27
|
+
// // User grants server permission to start sessions on their behalf
|
|
28
|
+
// const msg = buildAuthzGrantMsg(userAddr, serverAddr, MSG_TYPES.PLAN_START_SESSION);
|
|
29
|
+
// await broadcast(client, userAddr, [msg]);
|
|
30
|
+
// // Server can now start sessions for the user
|
|
31
|
+
// const innerMsg = { typeUrl: MSG_TYPES.PLAN_START_SESSION, value: { from: userAddr, ... } };
|
|
32
|
+
// const execMsg = buildAuthzExecMsg(serverAddr, encodeForExec([innerMsg]));
|
|
33
|
+
// await broadcast(serverClient, serverAddr, [execMsg]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a MsgGrant (authz) for a specific message type.
|
|
37
|
+
* @param {string} granter - Address granting permission (sent1...)
|
|
38
|
+
* @param {string} grantee - Address receiving permission (sent1...)
|
|
39
|
+
* @param {string} msgTypeUrl - Message type URL to authorize (e.g. MSG_TYPES.START_SESSION)
|
|
40
|
+
* @param {Date|string} expiration - Optional expiry date (default: no expiry)
|
|
41
|
+
*/
|
|
42
|
+
export function buildAuthzGrantMsg(granter, grantee, msgTypeUrl, expiration) {
|
|
43
|
+
const authBytes = encodeGenericAuthorization(msgTypeUrl);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
typeUrl: '/cosmos.authz.v1beta1.MsgGrant',
|
|
47
|
+
value: {
|
|
48
|
+
granter,
|
|
49
|
+
grantee,
|
|
50
|
+
grant: {
|
|
51
|
+
authorization: {
|
|
52
|
+
typeUrl: '/cosmos.authz.v1beta1.GenericAuthorization',
|
|
53
|
+
value: Uint8Array.from(authBytes),
|
|
54
|
+
},
|
|
55
|
+
expiration: expiration
|
|
56
|
+
? { seconds: BigInt(Math.floor((expiration instanceof Date ? expiration : new Date(expiration)).getTime() / 1000)), nanos: 0 }
|
|
57
|
+
: undefined,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a MsgRevoke (authz) to remove a specific grant.
|
|
65
|
+
*/
|
|
66
|
+
export function buildAuthzRevokeMsg(granter, grantee, msgTypeUrl) {
|
|
67
|
+
return {
|
|
68
|
+
typeUrl: '/cosmos.authz.v1beta1.MsgRevoke',
|
|
69
|
+
value: { granter, grantee, msgTypeUrl },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a MsgExec (authz) to execute messages on behalf of a granter.
|
|
75
|
+
* @param {string} grantee - Address executing on behalf of granter
|
|
76
|
+
* @param {Array} encodedMsgs - Pre-encoded messages (use encodeForExec() to prepare)
|
|
77
|
+
*/
|
|
78
|
+
export function buildAuthzExecMsg(grantee, encodedMsgs) {
|
|
79
|
+
return {
|
|
80
|
+
typeUrl: '/cosmos.authz.v1beta1.MsgExec',
|
|
81
|
+
value: { grantee, msgs: encodedMsgs },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Encode SDK message objects to the Any format required by MsgExec.
|
|
87
|
+
* @param {Array<{typeUrl: string, value: object}>} msgs - Standard SDK messages
|
|
88
|
+
* @returns {Array<{typeUrl: string, value: Uint8Array}>} Encoded messages for MsgExec
|
|
89
|
+
*/
|
|
90
|
+
export function encodeForExec(msgs) {
|
|
91
|
+
const reg = buildRegistry();
|
|
92
|
+
return msgs.map(msg => {
|
|
93
|
+
const type = reg.lookupType(msg.typeUrl);
|
|
94
|
+
if (!type) throw new ChainError(ErrorCodes.UNKNOWN_MSG_TYPE, `Unknown message type: ${msg.typeUrl}. Ensure it is registered in buildRegistry().`, { typeUrl: msg.typeUrl });
|
|
95
|
+
return {
|
|
96
|
+
typeUrl: msg.typeUrl,
|
|
97
|
+
value: type.encode(type.fromPartial(msg.value)).finish(),
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Query authz grants between granter and grantee.
|
|
104
|
+
* @returns {Promise<Array>} Array of grant objects
|
|
105
|
+
*/
|
|
106
|
+
export async function queryAuthzGrants(lcdUrl, granter, grantee) {
|
|
107
|
+
const { items } = await lcdPaginatedSafe(lcdUrl, `/cosmos/authz/v1beta1/grants?granter=${granter}&grantee=${grantee}`, 'grants');
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Chain / Broadcast Module
|
|
3
|
+
*
|
|
4
|
+
* TX broadcasting: simple broadcast, fee-granted broadcast,
|
|
5
|
+
* safe broadcaster (mutex + retry + sequence recovery + RPC rotation),
|
|
6
|
+
* chain error parsing, event extraction.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { broadcast, createSafeBroadcaster, broadcastWithFeeGrant } from './chain/broadcast.js';
|
|
10
|
+
* const result = await broadcast(client, addr, [msg]);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { SigningStargateClient, GasPrice } from '@cosmjs/stargate';
|
|
14
|
+
import { GAS_PRICE, RPC_ENDPOINTS } from '../defaults.js';
|
|
15
|
+
import { ChainError, ErrorCodes } from '../errors.js';
|
|
16
|
+
import { buildRegistry } from './client.js';
|
|
17
|
+
|
|
18
|
+
// ─── TX Helpers ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Simple broadcast — send messages and return result.
|
|
22
|
+
* For production apps with multiple TXs, use createSafeBroadcaster() instead.
|
|
23
|
+
*/
|
|
24
|
+
export async function broadcast(client, signerAddress, msgs, fee = null) {
|
|
25
|
+
// Fee validation: detect malformed fee objects and fall back to 'auto'
|
|
26
|
+
if (!fee || (typeof fee === 'object' && (!fee.gas || !fee.amount))) fee = 'auto';
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await client.signAndBroadcast(signerAddress, msgs, fee);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
// CosmJS on Node.js v18+ uses native fetch (undici) internally for RPC.
|
|
32
|
+
// Undici throws opaque "fetch failed" on network errors. Re-wrap with context.
|
|
33
|
+
const typeUrls = msgs.map(m => m.typeUrl).join(', ');
|
|
34
|
+
throw new ChainError(ErrorCodes.BROADCAST_FAILED, `Broadcast failed (${typeUrls}): ${err.message}`, { typeUrls, original: err.message });
|
|
35
|
+
}
|
|
36
|
+
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 });
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Safe Broadcast (Mutex + Retry + Sequence Recovery) ─────────────────────
|
|
41
|
+
// Production-critical: prevents sequence mismatch errors when sending
|
|
42
|
+
// multiple TXs rapidly (batch operations, auto-lease + link, UI clicks).
|
|
43
|
+
|
|
44
|
+
export function isSequenceError(errOrStr) {
|
|
45
|
+
// Check Cosmos SDK error code 32 (ErrWrongSequence) first
|
|
46
|
+
if (errOrStr?.code === 32) return true;
|
|
47
|
+
const s = typeof errOrStr === 'string' ? errOrStr : errOrStr?.message || String(errOrStr);
|
|
48
|
+
// Try parsing rawLog as JSON to extract error code
|
|
49
|
+
try { const parsed = JSON.parse(s); if (parsed?.code === 32) return true; } catch {} // not JSON — fall through to string match
|
|
50
|
+
// Fallback to string match (last resort — fragile across Cosmos SDK upgrades)
|
|
51
|
+
return s && (s.includes('account sequence mismatch') || s.includes('incorrect account sequence'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check if error is "wrong number of signers" — indicates stale client state, retryable with reconnect */
|
|
55
|
+
function isSignerError(errOrStr) {
|
|
56
|
+
const s = typeof errOrStr === 'string' ? errOrStr : errOrStr?.message || String(errOrStr);
|
|
57
|
+
return s && s.includes('wrong number of signers');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Check if error is a network/connection failure — triggers RPC endpoint rotation */
|
|
61
|
+
function isConnectionError(errOrStr) {
|
|
62
|
+
const s = typeof errOrStr === 'string' ? errOrStr : errOrStr?.message || String(errOrStr);
|
|
63
|
+
return s && (/fetch failed|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|ECONNRESET|ENETUNREACH|Query failed/i.test(s));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a safe broadcaster with mutex serialization and retry logic.
|
|
68
|
+
* Only one TX broadcasts at a time. Sequence errors trigger client reconnect + retry.
|
|
69
|
+
*
|
|
70
|
+
* Usage:
|
|
71
|
+
* const { safeBroadcast } = createSafeBroadcaster(rpcUrl, wallet, signerAddress);
|
|
72
|
+
* const result = await safeBroadcast([msg1, msg2]); // batch = one TX
|
|
73
|
+
*/
|
|
74
|
+
export function createSafeBroadcaster(rpcUrl, wallet, signerAddress) {
|
|
75
|
+
let _client = null;
|
|
76
|
+
let _queue = Promise.resolve();
|
|
77
|
+
// RPC rotation: cycle through endpoints on connection failures
|
|
78
|
+
const _rpcUrls = [rpcUrl, ...RPC_ENDPOINTS.map(e => e.url).filter(u => u !== rpcUrl)];
|
|
79
|
+
let _rpcIdx = 0;
|
|
80
|
+
|
|
81
|
+
function _currentRpc() { return _rpcUrls[_rpcIdx % _rpcUrls.length]; }
|
|
82
|
+
function _rotateRpc() { _rpcIdx = (_rpcIdx + 1) % _rpcUrls.length; return _currentRpc(); }
|
|
83
|
+
|
|
84
|
+
async function getClient() {
|
|
85
|
+
if (!_client) {
|
|
86
|
+
_client = await SigningStargateClient.connectWithSigner(_currentRpc(), wallet, {
|
|
87
|
+
gasPrice: GasPrice.fromString(GAS_PRICE),
|
|
88
|
+
registry: buildRegistry(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return _client;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resetClient(rotate = false) {
|
|
95
|
+
if (rotate) _rotateRpc();
|
|
96
|
+
_client = await SigningStargateClient.connectWithSigner(_currentRpc(), wallet, {
|
|
97
|
+
gasPrice: GasPrice.fromString(GAS_PRICE),
|
|
98
|
+
registry: buildRegistry(),
|
|
99
|
+
});
|
|
100
|
+
return _client;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function _inner(msgs, memo) {
|
|
104
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
105
|
+
let client;
|
|
106
|
+
if (attempt === 0) {
|
|
107
|
+
client = await getClient();
|
|
108
|
+
} else {
|
|
109
|
+
const delay = Math.min(2000 * attempt, 6000);
|
|
110
|
+
await new Promise(r => setTimeout(r, delay));
|
|
111
|
+
client = await resetClient(); // fresh connection = fresh sequence
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await client.signAndBroadcast(signerAddress, msgs, 'auto', memo);
|
|
116
|
+
if (result.code !== 0 && isSequenceError(result.rawLog)) continue;
|
|
117
|
+
return result;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (isSequenceError(err.message)) continue;
|
|
120
|
+
// "wrong number of signers" — stale client state, retryable with reconnect
|
|
121
|
+
if (isSignerError(err.message)) continue;
|
|
122
|
+
// Connection/network failure — rotate to next RPC endpoint
|
|
123
|
+
if (isConnectionError(err.message)) {
|
|
124
|
+
client = await resetClient(true);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Final attempt — try with rotated RPC
|
|
131
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
132
|
+
const client = await resetClient(true);
|
|
133
|
+
return client.signAndBroadcast(signerAddress, msgs, 'auto', memo);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function safeBroadcast(msgs, memo) {
|
|
137
|
+
const p = _queue.then(() => _inner(msgs, memo));
|
|
138
|
+
_queue = p.catch(() => {}); // don't break queue on failure
|
|
139
|
+
return p;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { safeBroadcast, getClient, resetClient };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Broadcast a TX with fee paid by a granter (fee grant).
|
|
147
|
+
* The grantee signs; the granter pays gas via their fee allowance.
|
|
148
|
+
* @param {SigningStargateClient} client - Client with grantee's wallet
|
|
149
|
+
* @param {string} signerAddress - Grantee address (sent1...)
|
|
150
|
+
* @param {Array} msgs - Messages to broadcast
|
|
151
|
+
* @param {string} granterAddress - Fee granter address (sent1...)
|
|
152
|
+
* @param {string} memo - Optional memo
|
|
153
|
+
*/
|
|
154
|
+
export async function broadcastWithFeeGrant(client, signerAddress, msgs, granterAddress, memo = '') {
|
|
155
|
+
// NOTE: client.simulate() does NOT support fee granter — it simulates without
|
|
156
|
+
// the granter field, causing "insufficient funds" if the grantee has low balance.
|
|
157
|
+
// Use fixed gas estimate instead. 300k gas covers all single-message Sentinel TXs.
|
|
158
|
+
// For multi-message batches, scale by message count.
|
|
159
|
+
const gasPerMsg = 200_000;
|
|
160
|
+
const gasLimit = Math.max(300_000, msgs.length * gasPerMsg);
|
|
161
|
+
const fee = {
|
|
162
|
+
amount: [{ denom: 'udvpn', amount: String(Math.ceil(gasLimit * 0.2)) }],
|
|
163
|
+
gas: String(gasLimit),
|
|
164
|
+
granter: granterAddress,
|
|
165
|
+
};
|
|
166
|
+
return client.signAndBroadcast(signerAddress, msgs, fee, memo);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Chain Error Parsing ────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse chain error messages into user-friendly text.
|
|
173
|
+
* Covers all known Sentinel-specific error patterns.
|
|
174
|
+
*/
|
|
175
|
+
export function parseChainError(raw) {
|
|
176
|
+
const s = String(raw || '');
|
|
177
|
+
if (s.includes('duplicate node for plan')) return 'Node is already in this plan';
|
|
178
|
+
if (s.includes('duplicate provider')) return 'Provider already registered — use Update';
|
|
179
|
+
if (s.includes('lease') && s.includes('not found')) return 'No active lease for this node';
|
|
180
|
+
if (s.includes('lease') && s.includes('already exists')) return 'Lease already exists for this node';
|
|
181
|
+
if (s.includes('insufficient funds')) return 'Insufficient P2P balance';
|
|
182
|
+
if (s.includes('invalid price')) return 'Price mismatch — node may have changed rates';
|
|
183
|
+
if (s.includes('invalid status inactive')) return 'Plan is inactive — activate first';
|
|
184
|
+
if (s.includes('plan') && s.includes('does not exist')) return 'Plan not found on chain';
|
|
185
|
+
if (s.includes('provider') && s.includes('does not exist')) return 'Provider not registered';
|
|
186
|
+
if (s.includes('node') && s.includes('does not exist')) return 'Node not found on chain';
|
|
187
|
+
if (s.includes('node') && s.includes('not active')) return 'Node is inactive';
|
|
188
|
+
if (s.includes('active session already exists')) return 'Session already exists for this node';
|
|
189
|
+
if (s.includes('subscription') && s.includes('not found')) return 'Subscription not found or expired';
|
|
190
|
+
if (s.includes('node address mismatch')) return 'Node address mismatch — wrong node at this URL';
|
|
191
|
+
if (s.includes('maximum peer limit')) return 'Node is full — maximum peer limit reached';
|
|
192
|
+
if (isSequenceError(s)) return 'Chain busy — sequence mismatch. Wait and retry.';
|
|
193
|
+
if (s.includes('out of gas')) return 'Transaction out of gas';
|
|
194
|
+
if (s.includes('timed out')) return 'Transaction timed out';
|
|
195
|
+
const m = s.match(/desc = (.+?)(?:\[|With gas|$)/);
|
|
196
|
+
if (m) return m[1].trim().slice(0, 120);
|
|
197
|
+
return s.slice(0, 150);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── TX Event Extraction ────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Extract an ID from TX ABCI events.
|
|
204
|
+
* Events may have base64-encoded keys/values depending on CosmJS version.
|
|
205
|
+
*
|
|
206
|
+
* Usage:
|
|
207
|
+
* extractId(result, /session/i, ['session_id', 'id'])
|
|
208
|
+
* extractId(result, /subscription/i, ['subscription_id', 'id'])
|
|
209
|
+
* extractId(result, /plan/i, ['plan_id', 'id'])
|
|
210
|
+
* extractId(result, /lease/i, ['lease_id', 'id'])
|
|
211
|
+
*/
|
|
212
|
+
export function extractId(txResult, eventPattern, keyNames) {
|
|
213
|
+
for (const event of (txResult.events || [])) {
|
|
214
|
+
if (eventPattern.test(event.type)) {
|
|
215
|
+
for (const attr of event.attributes) {
|
|
216
|
+
const k = typeof attr.key === 'string'
|
|
217
|
+
? attr.key
|
|
218
|
+
: Buffer.from(attr.key, 'base64').toString('utf8');
|
|
219
|
+
const v = typeof attr.value === 'string'
|
|
220
|
+
? attr.value
|
|
221
|
+
: Buffer.from(attr.value, 'base64').toString('utf8');
|
|
222
|
+
if (keyNames.includes(k)) {
|
|
223
|
+
const p = v.replace(/"/g, '');
|
|
224
|
+
if (p && parseInt(p) > 0) return p;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Decode base64-encoded TX events into readable key-value pairs.
|
|
234
|
+
* @param {Array} events - TX result events array
|
|
235
|
+
* @returns {Array<{ type: string, attributes: Array<{ key: string, value: string }> }>}
|
|
236
|
+
*/
|
|
237
|
+
export function decodeTxEvents(events) {
|
|
238
|
+
return (events || []).map(e => ({
|
|
239
|
+
type: e.type,
|
|
240
|
+
attributes: (e.attributes || []).map(a => ({
|
|
241
|
+
key: typeof a.key === 'string' ? a.key : Buffer.from(a.key, 'base64').toString('utf8'),
|
|
242
|
+
value: typeof a.value === 'string' ? a.value : Buffer.from(a.value, 'base64').toString('utf8'),
|
|
243
|
+
})),
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extract ALL session IDs from a batch TX result (multiple MsgStartSession).
|
|
249
|
+
* @param {DeliverTxResponse} txResult
|
|
250
|
+
* @returns {bigint[]}
|
|
251
|
+
*/
|
|
252
|
+
export function extractAllSessionIds(txResult) {
|
|
253
|
+
const ids = [];
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
const decoded = decodeTxEvents(txResult.events || []);
|
|
256
|
+
for (const evt of decoded) {
|
|
257
|
+
if (/session/i.test(evt.type)) {
|
|
258
|
+
for (const attr of evt.attributes) {
|
|
259
|
+
if (attr.key === 'session_id' || attr.key === 'SessionID' || attr.key === 'id') {
|
|
260
|
+
try {
|
|
261
|
+
const id = BigInt(attr.value.replace(/"/g, '')); // strip quotes from base64-decoded values
|
|
262
|
+
if (id > 0n && !seen.has(id)) { seen.add(id); ids.push(id); }
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return ids;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* BigInt-safe serialization of TX result (for logging/API responses).
|
|
273
|
+
*/
|
|
274
|
+
export function txResponse(result) {
|
|
275
|
+
return {
|
|
276
|
+
ok: result.code === 0,
|
|
277
|
+
txHash: result.transactionHash,
|
|
278
|
+
gasUsed: Number(result.gasUsed),
|
|
279
|
+
gasWanted: Number(result.gasWanted),
|
|
280
|
+
code: result.code,
|
|
281
|
+
rawLog: result.rawLog,
|
|
282
|
+
events: result.events,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Serialize a ConnectResult for JSON APIs. Handles BigInt → string conversion.
|
|
288
|
+
* Without this, JSON.stringify(connectResult) throws "BigInt can't be serialized".
|
|
289
|
+
*
|
|
290
|
+
* @param {object} result - ConnectResult from connectDirect/connectAuto/connectViaPlan
|
|
291
|
+
* @returns {object} JSON-safe object with sessionId as string
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* const conn = await connectDirect(opts);
|
|
295
|
+
* res.json(serializeResult(conn)); // Safe for Express response
|
|
296
|
+
*/
|
|
297
|
+
export function serializeResult(result) {
|
|
298
|
+
if (!result || typeof result !== 'object') return result;
|
|
299
|
+
const out = {};
|
|
300
|
+
for (const [key, val] of Object.entries(result)) {
|
|
301
|
+
if (typeof val === 'bigint') out[key] = String(val);
|
|
302
|
+
else if (typeof val === 'function') continue; // skip cleanup()
|
|
303
|
+
else out[key] = val;
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Estimate gas fee for a batch of messages.
|
|
310
|
+
* @param {number} msgCount
|
|
311
|
+
* @param {string} [msgType='startSession'] - 'startSession' | 'feeGrant' | 'send'
|
|
312
|
+
* @returns {{ gas: number, amount: number, fee: { amount: Array<{ denom: string, amount: string }>, gas: string } }}
|
|
313
|
+
*/
|
|
314
|
+
export function estimateBatchFee(msgCount, msgType = 'startSession') {
|
|
315
|
+
const gasPerMsg = { startSession: 200000, feeGrant: 150000, send: 80000, link: 150000 };
|
|
316
|
+
const base = gasPerMsg[msgType] || 200000;
|
|
317
|
+
const gas = base * msgCount;
|
|
318
|
+
const amount = Math.ceil(gas * 0.2); // GAS_PRICE = 0.2udvpn
|
|
319
|
+
return {
|
|
320
|
+
gas,
|
|
321
|
+
amount,
|
|
322
|
+
fee: { amount: [{ denom: 'udvpn', amount: String(amount) }], gas: String(gas) },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── Batch Message Builders ─────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Build batch MsgStartSession messages for multiple nodes in one TX.
|
|
330
|
+
* Saves gas vs separate TXs (~800k gas for 5 sessions vs 200k x 5 = 1M).
|
|
331
|
+
*
|
|
332
|
+
* @param {string} from - Wallet address (sent1...)
|
|
333
|
+
* @param {Array<{ nodeAddress: string, gigabytes?: number, maxPrice: { denom: string, base_value: string, quote_value: string } }>} nodes
|
|
334
|
+
* @returns {Array<{ typeUrl: string, value: object }>} Messages ready for broadcast()
|
|
335
|
+
*/
|
|
336
|
+
export function buildBatchStartSession(from, nodes) {
|
|
337
|
+
return nodes.map(n => ({
|
|
338
|
+
typeUrl: '/sentinel.node.v3.MsgStartSessionRequest',
|
|
339
|
+
value: {
|
|
340
|
+
from,
|
|
341
|
+
node_address: n.nodeAddress,
|
|
342
|
+
gigabytes: n.gigabytes || 1,
|
|
343
|
+
hours: 0,
|
|
344
|
+
max_price: n.maxPrice,
|
|
345
|
+
},
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Build MsgEndSession to close a session early (stop paying for bandwidth).
|
|
351
|
+
* @param {string} from - Wallet address
|
|
352
|
+
* @param {number|string|bigint} sessionId - Session to end
|
|
353
|
+
* @returns {{ typeUrl: string, value: object }}
|
|
354
|
+
*/
|
|
355
|
+
export function buildEndSessionMsg(from, sessionId) {
|
|
356
|
+
return {
|
|
357
|
+
typeUrl: '/sentinel.session.v3.MsgCancelSessionRequest',
|
|
358
|
+
value: { from, id: BigInt(sessionId) },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Build batch MsgSend messages for token distribution.
|
|
364
|
+
* @param {string} fromAddress
|
|
365
|
+
* @param {Array<{ address: string, amountUdvpn: number|string }>} recipients
|
|
366
|
+
* @returns {Array<{ typeUrl: string, value: object }>}
|
|
367
|
+
*/
|
|
368
|
+
export function buildBatchSend(fromAddress, recipients) {
|
|
369
|
+
return recipients.map(r => ({
|
|
370
|
+
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
|
|
371
|
+
value: { fromAddress, toAddress: r.address, amount: [{ denom: 'udvpn', amount: String(r.amountUdvpn) }] },
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Build batch MsgLinkNode messages for linking nodes to a plan.
|
|
377
|
+
* @param {string} provAddress - sentprov1... address
|
|
378
|
+
* @param {number|string|bigint} planId
|
|
379
|
+
* @param {string[]} nodeAddresses - sentnode1... addresses
|
|
380
|
+
* @returns {Array<{ typeUrl: string, value: object }>}
|
|
381
|
+
*/
|
|
382
|
+
export function buildBatchLink(provAddress, planId, nodeAddresses) {
|
|
383
|
+
return nodeAddresses.map(addr => ({
|
|
384
|
+
typeUrl: '/sentinel.plan.v3.MsgLinkNodeRequest',
|
|
385
|
+
value: { from: provAddress, id: BigInt(planId), node_address: addr },
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Send P2P tokens to an address.
|
|
391
|
+
* @param {SigningStargateClient} client
|
|
392
|
+
* @param {string} fromAddress
|
|
393
|
+
* @param {string} toAddress
|
|
394
|
+
* @param {number|string} amountUdvpn - Amount in micro-denom
|
|
395
|
+
* @param {string} [memo='']
|
|
396
|
+
* @returns {Promise<DeliverTxResponse>}
|
|
397
|
+
*/
|
|
398
|
+
export async function sendTokens(client, fromAddress, toAddress, amountUdvpn, memo = '') {
|
|
399
|
+
// Robust amount extraction: handle string, number, bigint, or coin object { amount, denom }
|
|
400
|
+
let amountStr;
|
|
401
|
+
if (amountUdvpn && typeof amountUdvpn === 'object') {
|
|
402
|
+
amountStr = String(amountUdvpn.amount || amountUdvpn.value || amountUdvpn);
|
|
403
|
+
} else {
|
|
404
|
+
amountStr = String(amountUdvpn);
|
|
405
|
+
}
|
|
406
|
+
if (!amountStr || amountStr === 'undefined' || amountStr === 'null' || amountStr === '[object Object]') {
|
|
407
|
+
throw new Error(`sendTokens: invalid amount "${amountUdvpn}" — expected string or number, got ${typeof amountUdvpn}`);
|
|
408
|
+
}
|
|
409
|
+
const msg = {
|
|
410
|
+
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
|
|
411
|
+
value: { fromAddress, toAddress, amount: [{ denom: 'udvpn', amount: amountStr }] },
|
|
412
|
+
};
|
|
413
|
+
return broadcast(client, fromAddress, [msg]);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Subscribe to a plan. Returns subscription ID from TX events.
|
|
418
|
+
* @param {SigningStargateClient} client
|
|
419
|
+
* @param {string} fromAddress
|
|
420
|
+
* @param {number|string|bigint} planId
|
|
421
|
+
* @param {string} [denom='udvpn']
|
|
422
|
+
* @returns {Promise<{ subscriptionId: bigint, txHash: string }>}
|
|
423
|
+
*/
|
|
424
|
+
export async function subscribeToPlan(client, fromAddress, planId, denom = 'udvpn') {
|
|
425
|
+
const msg = {
|
|
426
|
+
typeUrl: '/sentinel.subscription.v3.MsgStartSubscriptionRequest',
|
|
427
|
+
value: { from: fromAddress, id: BigInt(planId), denom, renewalPricePolicy: 0 },
|
|
428
|
+
};
|
|
429
|
+
const result = await broadcast(client, fromAddress, [msg]);
|
|
430
|
+
const subId = extractId(result, /subscription/i, ['subscription_id', 'id']);
|
|
431
|
+
if (!subId) throw new ChainError(ErrorCodes.SESSION_EXTRACT_FAILED, 'Failed to extract subscription ID from TX events', { txHash: result.transactionHash });
|
|
432
|
+
return { subscriptionId: BigInt(subId), txHash: result.transactionHash };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Estimate the cost of starting a session with a node.
|
|
437
|
+
* Supports both gigabyte and hourly pricing. When preferHourly is true and
|
|
438
|
+
* hourly pricing is available and cheaper, returns the hourly cost instead.
|
|
439
|
+
*
|
|
440
|
+
* @param {object} nodeInfo - Node LCD object with gigabyte_prices and optionally hourly_prices
|
|
441
|
+
* @param {number} [gigabytes=1] - Number of gigabytes (ignored when hourly pricing is selected)
|
|
442
|
+
* @param {{ preferHourly?: boolean, hours?: number }} [options] - Optional pricing mode
|
|
443
|
+
* @returns {{ udvpn: number, dvpn: number, gasUdvpn: number, totalUdvpn: number, mode: 'gigabyte'|'hourly', hourlyUdvpn?: number, gigabyteUdvpn?: number }}
|
|
444
|
+
*/
|
|
445
|
+
export function estimateSessionCost(nodeInfo, gigabytes = 1, options = {}) {
|
|
446
|
+
const gbPrices = nodeInfo.gigabyte_prices || nodeInfo.gigabytePrices || [];
|
|
447
|
+
const gbEntry = gbPrices.find(p => p.denom === 'udvpn');
|
|
448
|
+
const perGb = parseInt(gbEntry?.quote_value || gbEntry?.amount || '0', 10);
|
|
449
|
+
|
|
450
|
+
const hrPrices = nodeInfo.hourly_prices || nodeInfo.hourlyPrices || [];
|
|
451
|
+
const hrEntry = hrPrices.find(p => p.denom === 'udvpn');
|
|
452
|
+
const perHour = parseInt(hrEntry?.quote_value || hrEntry?.amount || '0', 10);
|
|
453
|
+
|
|
454
|
+
const hours = options.hours || 1;
|
|
455
|
+
const gbCost = perGb * gigabytes;
|
|
456
|
+
const hrCost = perHour * hours;
|
|
457
|
+
|
|
458
|
+
// Use hourly if preferHourly is set AND hourly pricing exists AND is cheaper
|
|
459
|
+
const useHourly = options.preferHourly && hrEntry && hrCost < gbCost;
|
|
460
|
+
const sessionCost = useHourly ? hrCost : gbCost;
|
|
461
|
+
const gasEstimate = 200000; // ~200k gas per MsgStartSession
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
udvpn: sessionCost,
|
|
465
|
+
dvpn: sessionCost / 1_000_000,
|
|
466
|
+
gasUdvpn: gasEstimate,
|
|
467
|
+
totalUdvpn: sessionCost + gasEstimate,
|
|
468
|
+
mode: useHourly ? 'hourly' : 'gigabyte',
|
|
469
|
+
hourlyUdvpn: perHour || null,
|
|
470
|
+
gigabyteUdvpn: perGb || null,
|
|
471
|
+
};
|
|
472
|
+
}
|