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/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 };
|