blue-js-sdk 2.7.0 → 2.7.2
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 +24 -0
- package/auth/adr36.js +81 -0
- package/auth/keplr-signdoc.js +97 -0
- package/auth/privy-cosmos-signer.js +298 -0
- package/defaults.js +38 -16
- package/operator/auto-lease.js +157 -0
- package/operator/batch-revoke.js +112 -0
- package/operator/feegrant-history.js +133 -0
- package/operator/plan-ownership.js +89 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
Every fix made during SDK creation, why it matters, and what happens if you use upstream Sentinel code directly without these fixes.
|
|
4
4
|
|
|
5
|
+
## v2.7.2 — Packaging Fix: include `auth/` and `operator/` in tarball (2026-05-02)
|
|
6
|
+
|
|
7
|
+
**2.7.1 shipped without `auth/` and `operator/` directories.** `index.js` imports
|
|
8
|
+
from both (`./auth/adr36.js`, `./operator/auto-lease.js`, etc.), but they were
|
|
9
|
+
absent from the `files` array in `package.json`. Local CI passed because every
|
|
10
|
+
relative import resolved on disk — but `npm install blue-js-sdk@2.7.1` threw
|
|
11
|
+
`ERR_MODULE_NOT_FOUND: ...auth/adr36.js` for every consumer. Plan Manager's
|
|
12
|
+
attempt to upgrade to 2.7.1 surfaced the regression.
|
|
13
|
+
|
|
14
|
+
### Fix
|
|
15
|
+
- `package.json` `files`: added `auth/` and `operator/` so they ship in the tarball.
|
|
16
|
+
- `.github/workflows/ci.yml`: added a "Verify published tarball imports cleanly"
|
|
17
|
+
step that runs `npm pack`, installs the resulting tarball into a temp directory,
|
|
18
|
+
and imports `blue-js-sdk` — the only test that exercises the actual published
|
|
19
|
+
surface. Local-import checks (which 2.7.1's CI relied on) cannot catch
|
|
20
|
+
packaging drift; only a tarball install can.
|
|
21
|
+
|
|
22
|
+
### Rule
|
|
23
|
+
**Every directory imported by `index.js` MUST appear in `package.json` "files".**
|
|
24
|
+
The tarball-install CI step is now the gate that enforces this. Adding a new
|
|
25
|
+
top-level directory? It must also be added to `files` in the same diff.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
5
29
|
## v2.3.0 — RPC-First Migration (2026-04-14)
|
|
6
30
|
|
|
7
31
|
**100% of chain queries now use RPC-first with LCD fallback.** Protobuf/ABCI queries via Tendermint37Client are ~912x faster than LCD REST. If RPC fails, every query automatically falls back to LCD.
|
package/auth/adr36.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — ADR-36 Signature Verification
|
|
3
|
+
*
|
|
4
|
+
* Verifies Keplr/Leap/Cosmostation `signArbitrary` signatures server-side.
|
|
5
|
+
* Includes address-vs-pubkey derivation check (the part most consumers skip).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { verifyAdr36Signature } from './auth/adr36.js';
|
|
9
|
+
* const { ok, reason } = await verifyAdr36Signature({ addr, pubkeyB64, signatureB64, message });
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Secp256k1, sha256, ripemd160 } from '@cosmjs/crypto';
|
|
13
|
+
import { toBech32 } from '@cosmjs/encoding';
|
|
14
|
+
|
|
15
|
+
// ─── Canonical JSON ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Amino-compatible canonical JSON serialization (sorted keys, no whitespace).
|
|
19
|
+
* Required for ADR-36 sign-doc hashing — standard JSON.stringify produces
|
|
20
|
+
* non-deterministic key order, which breaks signature verification.
|
|
21
|
+
*/
|
|
22
|
+
export function sortedJsonStringify(value) {
|
|
23
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
24
|
+
if (Array.isArray(value)) return '[' + value.map(sortedJsonStringify).join(',') + ']';
|
|
25
|
+
const keys = Object.keys(value).sort();
|
|
26
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + sortedJsonStringify(value[k])).join(',') + '}';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── ADR-36 Verifier ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Verify an ADR-36 `signArbitrary` signature produced by Keplr, Leap, or Cosmostation.
|
|
33
|
+
*
|
|
34
|
+
* Checks BOTH:
|
|
35
|
+
* 1. That the pubkey derives to the claimed address (addr-pubkey-mismatch attack vector).
|
|
36
|
+
* 2. That the secp256k1 signature over the canonical sign-doc is valid.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.addr - Bech32 signer address (e.g. sent1abc...)
|
|
40
|
+
* @param {string} opts.pubkeyB64 - Base64-encoded compressed secp256k1 pubkey (33 bytes)
|
|
41
|
+
* @param {string} opts.signatureB64 - Base64-encoded 64-byte signature (r||s)
|
|
42
|
+
* @param {string} opts.message - The original UTF-8 message that was signed
|
|
43
|
+
* @param {string} [opts.prefix] - Bech32 prefix (default: 'sent')
|
|
44
|
+
* @returns {Promise<{ ok: boolean, reason: string|null }>}
|
|
45
|
+
*/
|
|
46
|
+
export async function verifyAdr36Signature({ addr, pubkeyB64, signatureB64, message, prefix = 'sent' }) {
|
|
47
|
+
const pubkey = Buffer.from(pubkeyB64, 'base64');
|
|
48
|
+
|
|
49
|
+
// Step 1: derive bech32 address from pubkey and compare to claimed addr.
|
|
50
|
+
// Skipping this check allows an attacker to substitute their own pubkey for
|
|
51
|
+
// a victim's address and produce a valid-looking signature for any message.
|
|
52
|
+
const derived = toBech32(prefix, ripemd160(sha256(pubkey)));
|
|
53
|
+
if (derived !== addr) return { ok: false, reason: 'addr-pubkey-mismatch' };
|
|
54
|
+
|
|
55
|
+
// Step 2: rebuild the canonical ADR-36 amino sign-doc and verify the signature.
|
|
56
|
+
const signDoc = {
|
|
57
|
+
chain_id: '',
|
|
58
|
+
account_number: '0',
|
|
59
|
+
sequence: '0',
|
|
60
|
+
fee: { gas: '0', amount: [] },
|
|
61
|
+
msgs: [{
|
|
62
|
+
type: 'sign/MsgSignData',
|
|
63
|
+
value: {
|
|
64
|
+
signer: addr,
|
|
65
|
+
data: Buffer.from(message, 'utf8').toString('base64'),
|
|
66
|
+
},
|
|
67
|
+
}],
|
|
68
|
+
memo: '',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const hash = sha256(Buffer.from(sortedJsonStringify(signDoc), 'utf8'));
|
|
72
|
+
const sig = Buffer.from(signatureB64, 'base64');
|
|
73
|
+
|
|
74
|
+
const ok = await Secp256k1.verifySignature(
|
|
75
|
+
{ r: sig.slice(0, 32), s: sig.slice(32) },
|
|
76
|
+
hash,
|
|
77
|
+
pubkey,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return { ok, reason: ok ? null : 'sig-invalid' };
|
|
81
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Keplr Broadcast-Back Helpers
|
|
3
|
+
*
|
|
4
|
+
* Builds a SIGN_MODE_DIRECT signDoc for Keplr/Leap `signDirect`, then
|
|
5
|
+
* reassembles the signed TxRaw for RPC broadcast. This is the only pattern
|
|
6
|
+
* that works without leaking mnemonics or maintaining a long-lived signing
|
|
7
|
+
* client in the browser.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* server: buildKeplrSignDoc(msgs, account, fee) → { bodyBytes, authInfoBytes, chainId, accountNumber }
|
|
11
|
+
* browser: keplr.signDirect(chainId, addr, signDoc) → { signed, signature }
|
|
12
|
+
* server: broadcastSignedKeplrTx(tmClient, signed, signature) → txHash
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* import { buildKeplrSignDoc, broadcastSignedKeplrTx } from './auth/keplr-signdoc.js';
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { TxBody, TxRaw } from 'cosmjs-types/cosmos/tx/v1beta1/tx.js';
|
|
19
|
+
import { makeAuthInfoBytes } from '@cosmjs/proto-signing';
|
|
20
|
+
import Long from 'long';
|
|
21
|
+
|
|
22
|
+
// ─── SignDoc Builder ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a Direct-mode signDoc for Keplr's `signDirect`.
|
|
26
|
+
*
|
|
27
|
+
* Common traps avoided here:
|
|
28
|
+
* - `accountNumber` must be Long.toString(), not a JS Number — Keplr silently fails otherwise.
|
|
29
|
+
* - `SIGN_MODE_DIRECT` = 1 (not a named export in older cosmjs-types).
|
|
30
|
+
* - `pubkey.value` must be the raw 33-byte compressed pubkey bytes, NOT a protobuf wrapper.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {Array} opts.msgs - Array of { typeUrl, value } EncodeObjects
|
|
34
|
+
* @param {string} [opts.memo] - TX memo (default: '')
|
|
35
|
+
* @param {string} opts.pubkeyB64 - Base64-encoded compressed secp256k1 pubkey (33 bytes)
|
|
36
|
+
* @param {number} opts.accountNumber - Chain account number
|
|
37
|
+
* @param {number} opts.sequence - Account sequence
|
|
38
|
+
* @param {number} opts.gasLimit - Gas limit
|
|
39
|
+
* @param {Array} opts.feeAmount - Array of { denom, amount } Coin objects
|
|
40
|
+
* @param {string} opts.chainId - Chain ID (e.g. 'sentinelhub-2')
|
|
41
|
+
* @param {object} opts.registry - CosmJS TypeRegistry (from buildRegistry())
|
|
42
|
+
* @returns {{ bodyBytes: string, authInfoBytes: string, chainId: string, accountNumber: string }}
|
|
43
|
+
* All byte fields are base64-encoded for JSON transport to the browser.
|
|
44
|
+
*/
|
|
45
|
+
export function buildKeplrSignDoc({ msgs, memo = '', pubkeyB64, accountNumber, sequence, gasLimit, feeAmount, chainId, registry }) {
|
|
46
|
+
const bodyBytes = TxBody.encode({
|
|
47
|
+
messages: msgs.map(m => registry.encodeAsAny(m)),
|
|
48
|
+
memo,
|
|
49
|
+
timeoutHeight: Long.UZERO,
|
|
50
|
+
extensionOptions: [],
|
|
51
|
+
nonCriticalExtensionOptions: [],
|
|
52
|
+
}).finish();
|
|
53
|
+
|
|
54
|
+
const pubkeyBytes = Buffer.from(pubkeyB64, 'base64');
|
|
55
|
+
const authInfoBytes = makeAuthInfoBytes(
|
|
56
|
+
[{ pubkey: { typeUrl: '/cosmos.crypto.secp256k1.PubKey', value: pubkeyBytes }, sequence }],
|
|
57
|
+
feeAmount,
|
|
58
|
+
gasLimit,
|
|
59
|
+
undefined,
|
|
60
|
+
undefined,
|
|
61
|
+
1, // SIGN_MODE_DIRECT
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
bodyBytes: Buffer.from(bodyBytes).toString('base64'),
|
|
66
|
+
authInfoBytes: Buffer.from(authInfoBytes).toString('base64'),
|
|
67
|
+
chainId,
|
|
68
|
+
accountNumber: Long.fromNumber(accountNumber).toString(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Broadcast Reconstructor ─────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Reconstruct TxRaw from Keplr's `signDirect` response and broadcast via RPC.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} opts
|
|
78
|
+
* @param {object} opts.tmClient - Tendermint37Client (from createRpcQueryClient)
|
|
79
|
+
* @param {string} opts.bodyBytesB64 - `signed.bodyBytes` from Keplr (base64)
|
|
80
|
+
* @param {string} opts.authInfoBytesB64 - `signed.authInfoBytes` from Keplr (base64)
|
|
81
|
+
* @param {string} opts.signatureB64 - `signature.signature` from Keplr (base64)
|
|
82
|
+
* @returns {Promise<{ transactionHash: string, code: number }>}
|
|
83
|
+
*/
|
|
84
|
+
export async function broadcastSignedKeplrTx({ tmClient, bodyBytesB64, authInfoBytesB64, signatureB64 }) {
|
|
85
|
+
const txRaw = TxRaw.encode({
|
|
86
|
+
bodyBytes: Buffer.from(bodyBytesB64, 'base64'),
|
|
87
|
+
authInfoBytes: Buffer.from(authInfoBytesB64, 'base64'),
|
|
88
|
+
signatures: [Buffer.from(signatureB64, 'base64')],
|
|
89
|
+
}).finish();
|
|
90
|
+
|
|
91
|
+
const result = await tmClient.broadcastTxSync({ tx: txRaw });
|
|
92
|
+
return {
|
|
93
|
+
transactionHash: Buffer.from(result.hash).toString('hex').toUpperCase(),
|
|
94
|
+
code: result.code ?? 0,
|
|
95
|
+
rawLog: result.log ?? '',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Privy → Cosmos Signer Adapter
|
|
3
|
+
*
|
|
4
|
+
* Bridges a Privy embedded wallet (EVM/Solana-native) to a Sentinel Cosmos
|
|
5
|
+
* signer. The result satisfies cosmjs `OfflineDirectSigner`, so it can be
|
|
6
|
+
* passed straight to `SigningStargateClient.connectWithSigner` and to every
|
|
7
|
+
* Sentinel SDK helper that takes a `wallet`.
|
|
8
|
+
*
|
|
9
|
+
* Two strategies are supported, picked by the `mode` field on the input.
|
|
10
|
+
*
|
|
11
|
+
* ─── Mode A: 'mnemonic' (seed-import) ──────────────────────────────────────
|
|
12
|
+
* The consumer triggers Privy's `exportWallet()` once, captures the seed
|
|
13
|
+
* phrase the user reveals, and hands it to this adapter. The adapter derives
|
|
14
|
+
* a Cosmos secp256k1 key on Sentinel's HD path (cosmoshub-style, coinType
|
|
15
|
+
* 118) and wraps it in `DirectSecp256k1HdWallet`. Same trust model as a
|
|
16
|
+
* normal mnemonic wallet — the seed leaves Privy's secure enclave.
|
|
17
|
+
*
|
|
18
|
+
* Use this when you need full broadcast capability (sessions, payments,
|
|
19
|
+
* fee-grants) and your UX can prompt the user to export once.
|
|
20
|
+
*
|
|
21
|
+
* ─── Mode B: 'rawSign' (custody-preserving) ────────────────────────────────
|
|
22
|
+
* The seed never leaves Privy. The consumer supplies:
|
|
23
|
+
* - `pubkey`: the compressed secp256k1 pubkey (33 bytes) Privy derived for
|
|
24
|
+
* this user on the Cosmos `m/44'/118'/0'/0/0` path. Privy exposes raw
|
|
25
|
+
* signing for embedded wallets; deriving the pubkey from the same path
|
|
26
|
+
* yields the same `sent1...` address Mode A would produce.
|
|
27
|
+
* - `signRawSecp256k1(digest32)`: an async function that asks Privy to
|
|
28
|
+
* produce a 64-byte (r||s) signature over the supplied 32-byte digest,
|
|
29
|
+
* using the same key the pubkey came from. The adapter computes the
|
|
30
|
+
* digest of the cosmjs `SignDoc` itself, so Privy only sees a hash.
|
|
31
|
+
*
|
|
32
|
+
* Use this when you must keep custody inside Privy. Note that
|
|
33
|
+
* `signRawSecp256k1` MUST return a *normalized low-S* signature — cosmjs
|
|
34
|
+
* rejects high-S sigs. The adapter normalizes defensively.
|
|
35
|
+
*
|
|
36
|
+
* ─── Usage ─────────────────────────────────────────────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* // Mode A
|
|
39
|
+
* const signer = await PrivyCosmosSigner.fromMnemonic({
|
|
40
|
+
* mnemonic: privyExportedSeed,
|
|
41
|
+
* prefix: 'sent',
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* // Mode B
|
|
45
|
+
* const signer = await PrivyCosmosSigner.fromRawSign({
|
|
46
|
+
* pubkey: privyDerivedCompressedPubkey, // Uint8Array(33)
|
|
47
|
+
* signRawSecp256k1: async (digest32) => {
|
|
48
|
+
* const sig = await privy.signRawHash({ hash: digest32, curve: 'secp256k1' });
|
|
49
|
+
* return sig; // Uint8Array(64), r||s
|
|
50
|
+
* },
|
|
51
|
+
* prefix: 'sent',
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* const [account] = await signer.getAccounts();
|
|
55
|
+
* // account.address === 'sent1...'
|
|
56
|
+
* const client = await SigningStargateClient.connectWithSigner(rpc, signer, ...);
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
Bip39,
|
|
61
|
+
EnglishMnemonic,
|
|
62
|
+
Slip10,
|
|
63
|
+
Slip10Curve,
|
|
64
|
+
Secp256k1,
|
|
65
|
+
sha256,
|
|
66
|
+
ripemd160,
|
|
67
|
+
} from '@cosmjs/crypto';
|
|
68
|
+
import { makeCosmoshubPath } from '@cosmjs/amino';
|
|
69
|
+
import { DirectSecp256k1HdWallet, makeSignDoc } from '@cosmjs/proto-signing';
|
|
70
|
+
import { fromBech32, toBech32 } from '@cosmjs/encoding';
|
|
71
|
+
import { ValidationError, ErrorCodes } from '../errors/index.js';
|
|
72
|
+
|
|
73
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function assertPrefix(prefix) {
|
|
76
|
+
if (typeof prefix !== 'string' || prefix.length === 0) {
|
|
77
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS,
|
|
78
|
+
'PrivyCosmosSigner: prefix must be a non-empty string (e.g. "sent")',
|
|
79
|
+
{ prefix });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// secp256k1 group order n. Signatures with s > n/2 are non-canonical and
|
|
84
|
+
// rejected by Cosmos chains since cosmos-sdk v0.42. Privy's raw-sign path is
|
|
85
|
+
// not guaranteed to return low-S form, so we normalize defensively.
|
|
86
|
+
const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
|
|
87
|
+
const SECP256K1_HALF_N = SECP256K1_N >> 1n;
|
|
88
|
+
|
|
89
|
+
function bytesToBigInt(bytes) {
|
|
90
|
+
let n = 0n;
|
|
91
|
+
for (const b of bytes) n = (n << 8n) | BigInt(b);
|
|
92
|
+
return n;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function bigIntTo32Bytes(n) {
|
|
96
|
+
const out = new Uint8Array(32);
|
|
97
|
+
for (let i = 31; i >= 0; i--) {
|
|
98
|
+
out[i] = Number(n & 0xffn);
|
|
99
|
+
n >>= 8n;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeLowS(rawSig) {
|
|
105
|
+
const r = rawSig.slice(0, 32);
|
|
106
|
+
const sBytes = rawSig.slice(32, 64);
|
|
107
|
+
const s = bytesToBigInt(sBytes);
|
|
108
|
+
if (s <= SECP256K1_HALF_N) return rawSig;
|
|
109
|
+
const flipped = SECP256K1_N - s;
|
|
110
|
+
const out = new Uint8Array(64);
|
|
111
|
+
out.set(r, 0);
|
|
112
|
+
out.set(bigIntTo32Bytes(flipped), 32);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pubkeyToBech32Address(compressedPubkey, prefix) {
|
|
117
|
+
if (!(compressedPubkey instanceof Uint8Array) || compressedPubkey.length !== 33) {
|
|
118
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS,
|
|
119
|
+
'PrivyCosmosSigner: pubkey must be a 33-byte compressed secp256k1 Uint8Array',
|
|
120
|
+
{ length: compressedPubkey?.length });
|
|
121
|
+
}
|
|
122
|
+
const data = ripemd160(sha256(compressedPubkey));
|
|
123
|
+
return toBech32(prefix, data);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Mode A: seed-import via Privy exportWallet ─────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a signer from a mnemonic the user just exported from Privy.
|
|
130
|
+
*
|
|
131
|
+
* Internally this is `DirectSecp256k1HdWallet` with Sentinel's prefix — i.e.
|
|
132
|
+
* the same key + address the consumer would get from `createWallet()` if
|
|
133
|
+
* they typed the mnemonic in directly. The wrapper exists so a consumer can
|
|
134
|
+
* write the same code path regardless of which Privy mode they're in.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} opts
|
|
137
|
+
* @param {string} opts.mnemonic
|
|
138
|
+
* @param {string} [opts.prefix='sent']
|
|
139
|
+
* @returns {Promise<DirectSecp256k1HdWallet>} A cosmjs OfflineDirectSigner
|
|
140
|
+
*/
|
|
141
|
+
export async function privyCosmosSignerFromMnemonic({ mnemonic, prefix = 'sent' } = {}) {
|
|
142
|
+
assertPrefix(prefix);
|
|
143
|
+
if (typeof mnemonic !== 'string' || mnemonic.trim().split(/\s+/).length < 12) {
|
|
144
|
+
throw new ValidationError(ErrorCodes.INVALID_MNEMONIC,
|
|
145
|
+
'privyCosmosSignerFromMnemonic: mnemonic must be a 12+ word BIP39 string',
|
|
146
|
+
{ wordCount: typeof mnemonic === 'string' ? mnemonic.trim().split(/\s+/).length : 0 });
|
|
147
|
+
}
|
|
148
|
+
return DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convenience: derive the compressed secp256k1 pubkey + address from a
|
|
153
|
+
* mnemonic on Sentinel's HD path. Useful for pre-computing what the
|
|
154
|
+
* `sent1...` address WILL be in Mode B before plumbing the raw-sign callback.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} mnemonic
|
|
157
|
+
* @param {string} [prefix='sent']
|
|
158
|
+
* @returns {Promise<{ pubkey: Uint8Array, address: string }>}
|
|
159
|
+
*/
|
|
160
|
+
export async function deriveCosmosPubkeyFromMnemonic(mnemonic, prefix = 'sent') {
|
|
161
|
+
assertPrefix(prefix);
|
|
162
|
+
const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(mnemonic));
|
|
163
|
+
const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, makeCosmoshubPath(0));
|
|
164
|
+
const { pubkey } = await Secp256k1.makeKeypair(privkey);
|
|
165
|
+
const compressed = Secp256k1.compressPubkey(pubkey);
|
|
166
|
+
return { pubkey: compressed, address: pubkeyToBech32Address(compressed, prefix) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Mode B: raw-sign (Privy keeps custody) ─────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `OfflineDirectSigner` that delegates the actual ECDSA op to Privy. The
|
|
173
|
+
* private key never leaves Privy's enclave; we hash the SignDoc locally and
|
|
174
|
+
* ship the 32-byte digest to the supplied callback.
|
|
175
|
+
*
|
|
176
|
+
* Conforms to cosmjs `OfflineDirectSigner`:
|
|
177
|
+
* - `getAccounts()` → `[{ address, algo: 'secp256k1', pubkey }]`
|
|
178
|
+
* - `signDirect(signerAddress, signDoc)` → `{ signed, signature }`
|
|
179
|
+
*/
|
|
180
|
+
export class PrivyRawSignDirectSigner {
|
|
181
|
+
/**
|
|
182
|
+
* @param {object} opts
|
|
183
|
+
* @param {Uint8Array} opts.pubkey - 33-byte compressed secp256k1 pubkey
|
|
184
|
+
* @param {(digest: Uint8Array) => Promise<Uint8Array>} opts.signRawSecp256k1
|
|
185
|
+
* Returns a 64-byte (r||s) signature over `digest`. MUST be low-S
|
|
186
|
+
* normalized; the adapter re-normalizes defensively.
|
|
187
|
+
* @param {string} [opts.prefix='sent']
|
|
188
|
+
*/
|
|
189
|
+
constructor({ pubkey, signRawSecp256k1, prefix = 'sent' }) {
|
|
190
|
+
assertPrefix(prefix);
|
|
191
|
+
if (typeof signRawSecp256k1 !== 'function') {
|
|
192
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS,
|
|
193
|
+
'PrivyRawSignDirectSigner: signRawSecp256k1 must be a function',
|
|
194
|
+
{});
|
|
195
|
+
}
|
|
196
|
+
this._pubkey = pubkey;
|
|
197
|
+
this._sign = signRawSecp256k1;
|
|
198
|
+
this._prefix = prefix;
|
|
199
|
+
this._address = pubkeyToBech32Address(pubkey, prefix);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async getAccounts() {
|
|
203
|
+
return [{
|
|
204
|
+
address: this._address,
|
|
205
|
+
algo: 'secp256k1',
|
|
206
|
+
pubkey: this._pubkey,
|
|
207
|
+
}];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {string} signerAddress
|
|
212
|
+
* @param {import('@cosmjs/proto-signing').SignDoc} signDoc
|
|
213
|
+
*/
|
|
214
|
+
async signDirect(signerAddress, signDoc) {
|
|
215
|
+
if (signerAddress !== this._address) {
|
|
216
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS,
|
|
217
|
+
`PrivyRawSignDirectSigner: signerAddress mismatch (got ${signerAddress}, signer holds ${this._address})`,
|
|
218
|
+
{ expected: this._address, got: signerAddress });
|
|
219
|
+
}
|
|
220
|
+
// Re-encode the SignDoc the same way cosmjs does, then SHA-256 it.
|
|
221
|
+
// Importing the raw protobuf encoder via makeSignDoc → makeSignBytes
|
|
222
|
+
// would also work, but doing it inline keeps the dep surface tight.
|
|
223
|
+
const { makeSignBytes } = await import('@cosmjs/proto-signing');
|
|
224
|
+
const signBytes = makeSignBytes(signDoc);
|
|
225
|
+
const digest = sha256(signBytes);
|
|
226
|
+
|
|
227
|
+
const rawSig = await this._sign(digest);
|
|
228
|
+
if (!(rawSig instanceof Uint8Array) || rawSig.length !== 64) {
|
|
229
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS,
|
|
230
|
+
'PrivyRawSignDirectSigner: signRawSecp256k1 must return a 64-byte (r||s) Uint8Array',
|
|
231
|
+
{ length: rawSig?.length });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Normalize to low-S so chain validators accept it. cosmjs's
|
|
235
|
+
// Secp256k1Signature.fromFixedLength + Secp256k1.trimRecoveryByte path
|
|
236
|
+
// is internal; instead we parse r/s as bigints and flip s if it sits in
|
|
237
|
+
// the upper half of the curve order.
|
|
238
|
+
const normalized = normalizeLowS(rawSig);
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
signed: signDoc,
|
|
242
|
+
signature: {
|
|
243
|
+
pub_key: {
|
|
244
|
+
type: 'tendermint/PubKeySecp256k1',
|
|
245
|
+
value: Buffer.from(this._pubkey).toString('base64'),
|
|
246
|
+
},
|
|
247
|
+
signature: Buffer.from(normalized).toString('base64'),
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Build a Mode B signer.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} opts
|
|
257
|
+
* @param {Uint8Array} opts.pubkey
|
|
258
|
+
* @param {(digest: Uint8Array) => Promise<Uint8Array>} opts.signRawSecp256k1
|
|
259
|
+
* @param {string} [opts.prefix='sent']
|
|
260
|
+
* @returns {Promise<PrivyRawSignDirectSigner>}
|
|
261
|
+
*/
|
|
262
|
+
export async function privyCosmosSignerFromRawSign(opts) {
|
|
263
|
+
return new PrivyRawSignDirectSigner(opts);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Unified factory ────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Single entry point picking the right strategy by `mode`.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ mode: 'mnemonic', mnemonic: string, prefix?: string }
|
|
272
|
+
* | { mode: 'rawSign', pubkey: Uint8Array,
|
|
273
|
+
* signRawSecp256k1: (digest: Uint8Array) => Promise<Uint8Array>,
|
|
274
|
+
* prefix?: string }} opts
|
|
275
|
+
* @returns {Promise<DirectSecp256k1HdWallet | PrivyRawSignDirectSigner>}
|
|
276
|
+
*/
|
|
277
|
+
export async function createPrivyCosmosSigner(opts = {}) {
|
|
278
|
+
if (opts.mode === 'mnemonic') {
|
|
279
|
+
return privyCosmosSignerFromMnemonic(opts);
|
|
280
|
+
}
|
|
281
|
+
if (opts.mode === 'rawSign') {
|
|
282
|
+
return privyCosmosSignerFromRawSign(opts);
|
|
283
|
+
}
|
|
284
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS,
|
|
285
|
+
`createPrivyCosmosSigner: unknown mode "${opts.mode}" — expected "mnemonic" or "rawSign"`,
|
|
286
|
+
{ mode: opts.mode });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Static facade ──────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export class PrivyCosmosSigner {
|
|
292
|
+
static fromMnemonic(opts) { return privyCosmosSignerFromMnemonic(opts); }
|
|
293
|
+
static fromRawSign(opts) { return privyCosmosSignerFromRawSign(opts); }
|
|
294
|
+
static create(opts) { return createPrivyCosmosSigner(opts); }
|
|
295
|
+
static derivePubkeyFromMnemonic(mnemonic, prefix) {
|
|
296
|
+
return deriveCosmosPubkeyFromMnemonic(mnemonic, prefix);
|
|
297
|
+
}
|
|
298
|
+
}
|
package/defaults.js
CHANGED
|
@@ -25,12 +25,14 @@ axios.defaults.adapter = 'http';
|
|
|
25
25
|
// This is the npm/semver version for consumers. Internal development iterations
|
|
26
26
|
// (v20, v21, v22, etc.) track feature milestones and are not exposed as exports.
|
|
27
27
|
|
|
28
|
-
export const SDK_VERSION = '2.
|
|
28
|
+
export const SDK_VERSION = '2.7.1';
|
|
29
29
|
|
|
30
30
|
// ─── Timestamps ──────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
-
/** When these defaults were last verified against the live chain
|
|
33
|
-
|
|
32
|
+
/** When these defaults were last verified against the live chain.
|
|
33
|
+
* RPC + LCD endpoint lists refreshed 2026-05-02 (audit-rpc-endpoints.mjs).
|
|
34
|
+
* Other defaults (gas, transport rates, etc.) still 2026-03-08. */
|
|
35
|
+
export const LAST_VERIFIED = '2026-05-02T00:00:00Z';
|
|
34
36
|
|
|
35
37
|
/** Human-readable note for builders */
|
|
36
38
|
export const HARDCODED_NOTE = 'Static defaults — no RPC query server yet. Verify endpoints are live before production use. See README.md "Hardcoded Defaults" section.';
|
|
@@ -44,28 +46,48 @@ export const CHAIN_VERSION = 'v12.0.0'; // sentinelhub version
|
|
|
44
46
|
export const COSMOS_SDK_VERSION = '0.47.17';
|
|
45
47
|
|
|
46
48
|
// ─── RPC Endpoints (TX broadcast) ────────────────────────────────────────────
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
+
// Latency-sorted list of endpoints that passed consensus health check on
|
|
50
|
+
// 2026-05-02. "Healthy" means: connects, /status reports catching_up=false,
|
|
51
|
+
// and the bank balance for a known address matches the modal answer across
|
|
52
|
+
// all responding candidates within 50 blocks of tip. Run
|
|
53
|
+
// `node tools/audit-rpc-endpoints.mjs` to refresh.
|
|
54
|
+
//
|
|
55
|
+
// rpc.sentinel.co / lcd.sentinel.co are intentionally excluded: on 2026-05-02
|
|
56
|
+
// they were ~22k blocks behind tip and returning 0 for funded addresses while
|
|
57
|
+
// reporting catching_up=false on /status. Consumers that need them can add
|
|
58
|
+
// them at runtime via addRpcEndpoint() / addLcdEndpoint().
|
|
49
59
|
|
|
50
60
|
export const RPC_ENDPOINTS = [
|
|
51
|
-
{ url: 'https://rpc
|
|
52
|
-
{ url: 'https://
|
|
53
|
-
{ url: 'https://rpc.
|
|
54
|
-
{ url: 'https://sentinel-rpc.
|
|
55
|
-
{ url: 'https://rpc.
|
|
61
|
+
{ url: 'https://rpc-sentinel.busurnode.com', name: 'Busurnode', verified: '2026-05-02' },
|
|
62
|
+
{ url: 'https://rpc.trinitystake.io', name: 'Trinity Stake', verified: '2026-05-02' },
|
|
63
|
+
{ url: 'https://sentinel-rpc.publicnode.com', name: 'PublicNode (Allnodes)', verified: '2026-05-02' },
|
|
64
|
+
{ url: 'https://sentinel-rpc.polkachu.com', name: 'Polkachu', verified: '2026-05-02' },
|
|
65
|
+
{ url: 'https://rpc.mathnodes.com', name: 'MathNodes', verified: '2026-05-02' },
|
|
66
|
+
{ url: 'https://rpc.dvpn.roomit.xyz', name: 'Roomit', verified: '2026-05-02' },
|
|
67
|
+
{ url: 'https://rpc.sentinel.suchnode.net', name: 'SuchNode', verified: '2026-05-02' },
|
|
68
|
+
{ url: 'https://rpc.sentinel.chaintools.tech', name: 'ChainTools', verified: '2026-05-02' },
|
|
69
|
+
{ url: 'https://rpc.sentinel.validatus.com', name: 'Validatus', verified: '2026-05-02' },
|
|
70
|
+
{ url: 'https://rpc.sentinel.quokkastake.io', name: 'QuokkaStake', verified: '2026-05-02' },
|
|
71
|
+
{ url: 'https://rpc.sentineldao.com', name: 'Sentinel Growth DAO', verified: '2026-05-02' },
|
|
72
|
+
{ url: 'https://rpc-sentinel.chainvibes.com', name: 'ChainVibes', verified: '2026-05-02' },
|
|
56
73
|
];
|
|
57
74
|
|
|
58
75
|
export const DEFAULT_RPC = RPC_ENDPOINTS[0].url;
|
|
59
76
|
|
|
60
77
|
// ─── LCD Endpoints (REST queries) ────────────────────────────────────────────
|
|
61
|
-
//
|
|
62
|
-
//
|
|
78
|
+
// Same consensus methodology as RPC. lcd.sentinel.co excluded for the same
|
|
79
|
+
// stale-state reason.
|
|
63
80
|
|
|
64
81
|
export const LCD_ENDPOINTS = [
|
|
65
|
-
{ url: 'https://
|
|
66
|
-
{ url: 'https://sentinel-
|
|
67
|
-
{ url: 'https://api.sentinel.
|
|
68
|
-
{ url: 'https://sentinel-
|
|
82
|
+
{ url: 'https://api-sentinel.busurnode.com', name: 'Busurnode', verified: '2026-05-02' },
|
|
83
|
+
{ url: 'https://sentinel-rest.publicnode.com', name: 'PublicNode (Allnodes)', verified: '2026-05-02' },
|
|
84
|
+
{ url: 'https://api.sentinel.suchnode.net', name: 'SuchNode', verified: '2026-05-02' },
|
|
85
|
+
{ url: 'https://sentinel-api.polkachu.com', name: 'Polkachu', verified: '2026-05-02' },
|
|
86
|
+
{ url: 'https://api.dvpn.roomit.xyz', name: 'Roomit', verified: '2026-05-02' },
|
|
87
|
+
{ url: 'https://api.sentinel.quokkastake.io', name: 'QuokkaStake', verified: '2026-05-02' },
|
|
88
|
+
{ url: 'https://api.sentinel.chaintools.tech', name: 'ChainTools', verified: '2026-05-02' },
|
|
89
|
+
{ url: 'https://api-sentinel.chainvibes.com', name: 'ChainVibes', verified: '2026-05-02' },
|
|
90
|
+
{ url: 'https://api.sentinel.validatus.com', name: 'Validatus', verified: '2026-05-02' },
|
|
69
91
|
];
|
|
70
92
|
|
|
71
93
|
export const DEFAULT_LCD = LCD_ENDPOINTS[0].url;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Operator / Auto Lease
|
|
3
|
+
*
|
|
4
|
+
* Lease multiple nodes onto a plan in optimistic batches.
|
|
5
|
+
* The chain rejects more than ~20 MsgStartLeaseRequest messages per TX —
|
|
6
|
+
* perTxLimit=20 is the empirically validated safe cap.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { batchLeaseNodes } from 'sentinel-dvpn-sdk/operator';
|
|
10
|
+
* const results = await batchLeaseNodes({
|
|
11
|
+
* client, providerAddress: 'sentprov1...', planId: 42,
|
|
12
|
+
* nodes: [{ nodeAddress: 'sentnode1...', hours: 720, maxPrice: { denom: 'udvpn', base_value: '...', quote_value: '...' } }],
|
|
13
|
+
* onProgress: (info) => console.log(info),
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { encodeMsgStartLease } from '../plan-operations.js';
|
|
18
|
+
import { broadcast } from '../chain/broadcast.js';
|
|
19
|
+
import { ValidationError, ErrorCodes } from '../errors.js';
|
|
20
|
+
|
|
21
|
+
const PER_TX_LIMIT = 20; // empirically validated chain cap for MsgStartLeaseRequest batch
|
|
22
|
+
const SLEEP_MS = 7000; // 7s between TX batches (chain rate-limit safe)
|
|
23
|
+
const GAS_PER_MSG = 120_000;
|
|
24
|
+
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise(r => setTimeout(r, ms));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lease a single node onto a plan.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {import('@cosmjs/stargate').SigningStargateClient} opts.client - Signed with provider's wallet
|
|
34
|
+
* @param {string} opts.providerAddress - sentprov1... prefix (plan owner)
|
|
35
|
+
* @param {{ nodeAddress: string, hours: number, maxPrice: { denom: string, base_value: string, quote_value: string } }} opts.node
|
|
36
|
+
* @param {number} [opts.gasPerMsg] - Gas per MsgStartLeaseRequest (default: 120000)
|
|
37
|
+
* @returns {Promise<{ ok: boolean, txHash?: string, error?: string }>}
|
|
38
|
+
*/
|
|
39
|
+
export async function autoLeaseNode(opts = {}) {
|
|
40
|
+
const { client, providerAddress, node, gasPerMsg = GAS_PER_MSG } = opts;
|
|
41
|
+
if (!client) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'client is required');
|
|
42
|
+
if (!providerAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'providerAddress is required');
|
|
43
|
+
if (!node?.nodeAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'node.nodeAddress is required');
|
|
44
|
+
if (!node?.hours || node.hours < 1) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'node.hours must be >= 1');
|
|
45
|
+
if (!node?.maxPrice) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'node.maxPrice is required');
|
|
46
|
+
|
|
47
|
+
const msg = {
|
|
48
|
+
typeUrl: '/sentinel.lease.v1.MsgStartLeaseRequest',
|
|
49
|
+
value: encodeMsgStartLease({
|
|
50
|
+
from: providerAddress,
|
|
51
|
+
nodeAddress: node.nodeAddress,
|
|
52
|
+
hours: node.hours,
|
|
53
|
+
maxPrice: node.maxPrice,
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
const fee = {
|
|
57
|
+
amount: [{ denom: 'udvpn', amount: String(gasPerMsg) }],
|
|
58
|
+
gas: String(gasPerMsg),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = await broadcast(client, providerAddress, [msg], fee);
|
|
63
|
+
if (result.code === 0) {
|
|
64
|
+
return { ok: true, txHash: result.transactionHash };
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: result.rawLog || `code=${result.code}` };
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { ok: false, error: e.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lease multiple nodes onto a plan in batches.
|
|
74
|
+
*
|
|
75
|
+
* Splits nodes into chunks of `perTxLimit` (default: 20, the empirical
|
|
76
|
+
* chain cap for MsgStartLeaseRequest per TX). Sleeps 7s between each
|
|
77
|
+
* chunk broadcast to avoid sequence errors.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} opts
|
|
80
|
+
* @param {import('@cosmjs/stargate').SigningStargateClient} opts.client - Signed with provider's wallet
|
|
81
|
+
* @param {string} opts.providerAddress - sentprov1... prefix (plan owner)
|
|
82
|
+
* @param {Array<{ nodeAddress: string, hours: number, maxPrice: { denom: string, base_value: string, quote_value: string } }>} opts.nodes
|
|
83
|
+
* @param {number} [opts.perTxLimit=20] - Max MsgStartLeaseRequest per TX
|
|
84
|
+
* @param {number} [opts.gasPerMsg=120000] - Gas per message
|
|
85
|
+
* @param {(info: { stage: string, batch: number, totalBatches: number, done: number, total: number, txHash?: string, error?: string }) => void} [opts.onProgress]
|
|
86
|
+
* @returns {Promise<Array<{ nodeAddress: string, ok: boolean, txHash?: string, error?: string }>>}
|
|
87
|
+
*/
|
|
88
|
+
export async function batchLeaseNodes(opts = {}) {
|
|
89
|
+
const {
|
|
90
|
+
client,
|
|
91
|
+
providerAddress,
|
|
92
|
+
nodes,
|
|
93
|
+
perTxLimit = PER_TX_LIMIT,
|
|
94
|
+
gasPerMsg = GAS_PER_MSG,
|
|
95
|
+
onProgress,
|
|
96
|
+
} = opts;
|
|
97
|
+
|
|
98
|
+
if (!client) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'client is required');
|
|
99
|
+
if (!providerAddress) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'providerAddress is required');
|
|
100
|
+
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
101
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'nodes must be a non-empty array');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const total = nodes.length;
|
|
105
|
+
const totalBatches = Math.ceil(total / perTxLimit);
|
|
106
|
+
const results = [];
|
|
107
|
+
const report = (info) => { if (onProgress) onProgress({ done: results.length, total, totalBatches, ...info }); };
|
|
108
|
+
|
|
109
|
+
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
110
|
+
const chunk = nodes.slice(batchIdx * perTxLimit, (batchIdx + 1) * perTxLimit);
|
|
111
|
+
const batch = batchIdx + 1;
|
|
112
|
+
|
|
113
|
+
report({ stage: 'batch_start', batch });
|
|
114
|
+
|
|
115
|
+
const msgs = chunk.map(n => ({
|
|
116
|
+
typeUrl: '/sentinel.lease.v1.MsgStartLeaseRequest',
|
|
117
|
+
value: encodeMsgStartLease({
|
|
118
|
+
from: providerAddress,
|
|
119
|
+
nodeAddress: n.nodeAddress,
|
|
120
|
+
hours: n.hours,
|
|
121
|
+
maxPrice: n.maxPrice,
|
|
122
|
+
}),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
const fee = {
|
|
126
|
+
amount: [{ denom: 'udvpn', amount: String(gasPerMsg * chunk.length) }],
|
|
127
|
+
gas: String(gasPerMsg * chunk.length),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const result = await broadcast(client, providerAddress, msgs, fee);
|
|
132
|
+
if (result.code === 0) {
|
|
133
|
+
for (const n of chunk) {
|
|
134
|
+
results.push({ nodeAddress: n.nodeAddress, ok: true, txHash: result.transactionHash });
|
|
135
|
+
}
|
|
136
|
+
report({ stage: 'batch_ok', batch, txHash: result.transactionHash });
|
|
137
|
+
} else {
|
|
138
|
+
// Batch failed — record all in chunk as failed
|
|
139
|
+
const error = result.rawLog || `code=${result.code}`;
|
|
140
|
+
for (const n of chunk) {
|
|
141
|
+
results.push({ nodeAddress: n.nodeAddress, ok: false, error });
|
|
142
|
+
}
|
|
143
|
+
report({ stage: 'batch_failed', batch, error });
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
for (const n of chunk) {
|
|
147
|
+
results.push({ nodeAddress: n.nodeAddress, ok: false, error: e.message });
|
|
148
|
+
}
|
|
149
|
+
report({ stage: 'batch_failed', batch, error: e.message });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Sleep 7s between batches (not after last batch)
|
|
153
|
+
if (batchIdx < totalBatches - 1) await sleep(SLEEP_MS);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return results;
|
|
157
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Operator / Batch Fee Grant Revoke
|
|
3
|
+
*
|
|
4
|
+
* Revoke fee grants from multiple grantees in optimistic batches.
|
|
5
|
+
* Tries all grantees in a single TX first; falls back to per-grantee
|
|
6
|
+
* TXs if the batch fails (e.g. one grantee has no active grant).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { batchRevokeFeeGrants } from 'sentinel-dvpn-sdk/operator';
|
|
10
|
+
* const results = await batchRevokeFeeGrants({
|
|
11
|
+
* client, granter: 'sent1...', grantees: ['sent1...', 'sent1...'],
|
|
12
|
+
* onProgress: (info) => console.log(info),
|
|
13
|
+
* });
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { buildRevokeFeeGrantMsg } from '../chain/fee-grants.js';
|
|
17
|
+
import { broadcast } from '../chain/broadcast.js';
|
|
18
|
+
import { ValidationError, ErrorCodes } from '../errors.js';
|
|
19
|
+
|
|
20
|
+
const SLEEP_MS = 7000;
|
|
21
|
+
|
|
22
|
+
function sleep(ms) {
|
|
23
|
+
return new Promise(r => setTimeout(r, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Batch-revoke fee grants from a list of grantees.
|
|
28
|
+
*
|
|
29
|
+
* Attempts to revoke all grantees in a single TX (optimistic batch).
|
|
30
|
+
* If the batch TX fails, falls back to one TX per grantee with 7s
|
|
31
|
+
* sleep between each (chain rate-limit safe).
|
|
32
|
+
*
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {import('@cosmjs/stargate').SigningStargateClient} opts.client - Signed with granter's wallet
|
|
35
|
+
* @param {string} opts.granter - Address revoking grants (sent1...)
|
|
36
|
+
* @param {string[]} opts.grantees - Addresses to revoke
|
|
37
|
+
* @param {number} [opts.gasPerMsg=80000] - Gas estimate per revoke message
|
|
38
|
+
* @param {(info: {stage: string, grantee?: string, txHash?: string, error?: string, done: number, total: number}) => void} [opts.onProgress]
|
|
39
|
+
* @returns {Promise<Array<{ grantee: string, ok: boolean, txHash?: string, error?: string }>>}
|
|
40
|
+
*/
|
|
41
|
+
export async function batchRevokeFeeGrants(opts = {}) {
|
|
42
|
+
const { client, granter, grantees, gasPerMsg = 80_000, onProgress } = opts;
|
|
43
|
+
|
|
44
|
+
if (!client) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'client is required');
|
|
45
|
+
if (!granter) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'granter is required');
|
|
46
|
+
if (!Array.isArray(grantees) || grantees.length === 0) {
|
|
47
|
+
throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'grantees must be a non-empty array');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const unique = [...new Set(grantees.filter(g => g && g !== granter))];
|
|
51
|
+
if (unique.length === 0) return [];
|
|
52
|
+
|
|
53
|
+
const total = unique.length;
|
|
54
|
+
const results = [];
|
|
55
|
+
const report = (info) => { if (onProgress) onProgress({ done: results.length, total, ...info }); };
|
|
56
|
+
|
|
57
|
+
// ── Optimistic batch attempt ────────────────────────────────────────────────
|
|
58
|
+
const batchMsgs = unique.map(g => buildRevokeFeeGrantMsg(granter, g));
|
|
59
|
+
report({ stage: 'batch_attempt' });
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const fee = {
|
|
63
|
+
amount: [{ denom: 'udvpn', amount: String(gasPerMsg * unique.length) }],
|
|
64
|
+
gas: String(gasPerMsg * unique.length),
|
|
65
|
+
};
|
|
66
|
+
const result = await broadcast(client, granter, batchMsgs, fee);
|
|
67
|
+
if (result.code === 0) {
|
|
68
|
+
// All succeeded in one TX
|
|
69
|
+
for (const grantee of unique) {
|
|
70
|
+
results.push({ grantee, ok: true, txHash: result.transactionHash });
|
|
71
|
+
report({ stage: 'revoked', grantee, txHash: result.transactionHash });
|
|
72
|
+
}
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
// Non-zero code — fall through to per-grantee
|
|
76
|
+
report({ stage: 'batch_failed', error: result.rawLog || `code=${result.code}` });
|
|
77
|
+
} catch (batchErr) {
|
|
78
|
+
report({ stage: 'batch_failed', error: batchErr.message });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Per-grantee fallback ────────────────────────────────────────────────────
|
|
82
|
+
report({ stage: 'fallback_start' });
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < unique.length; i++) {
|
|
85
|
+
const grantee = unique[i];
|
|
86
|
+
const msg = buildRevokeFeeGrantMsg(granter, grantee);
|
|
87
|
+
const fee = {
|
|
88
|
+
amount: [{ denom: 'udvpn', amount: String(gasPerMsg) }],
|
|
89
|
+
gas: String(gasPerMsg),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const result = await broadcast(client, granter, [msg], fee);
|
|
94
|
+
if (result.code === 0) {
|
|
95
|
+
results.push({ grantee, ok: true, txHash: result.transactionHash });
|
|
96
|
+
report({ stage: 'revoked', grantee, txHash: result.transactionHash });
|
|
97
|
+
} else {
|
|
98
|
+
const error = result.rawLog || `code=${result.code}`;
|
|
99
|
+
results.push({ grantee, ok: false, error });
|
|
100
|
+
report({ stage: 'revoke_failed', grantee, error });
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
results.push({ grantee, ok: false, error: e.message });
|
|
104
|
+
report({ stage: 'revoke_failed', grantee, error: e.message });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 7s gap between TXs (not needed after last one)
|
|
108
|
+
if (i < unique.length - 1) await sleep(SLEEP_MS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Operator / Fee Grant History
|
|
3
|
+
*
|
|
4
|
+
* Query the historical record of MsgGrantAllowance and MsgRevokeAllowance
|
|
5
|
+
* transactions for an address. Uses tmClient.txSearchAll() (RPC) for
|
|
6
|
+
* reliable historical lookup without LCD indexer dependencies.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { queryFeeGrantHistory } from 'sentinel-dvpn-sdk/operator';
|
|
10
|
+
* const history = await queryFeeGrantHistory(tmClient, 'sent1...');
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ValidationError, ErrorCodes } from '../errors.js';
|
|
14
|
+
|
|
15
|
+
// ─── Attribute Decoder ───────────────────────────────────────────────────────
|
|
16
|
+
// Cosmos SDK v0.47+ sometimes JSON-quotes string attribute values: `"sent1..."`.
|
|
17
|
+
// We strip those quotes so callers always get plain strings.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read an event attribute value by key. Handles JSON-quoted values from v0.47+.
|
|
21
|
+
* @param {ReadonlyArray<{key:string, value:string}>} attrs
|
|
22
|
+
* @param {string} key
|
|
23
|
+
* @returns {string|null}
|
|
24
|
+
*/
|
|
25
|
+
export function attr(attrs, key) {
|
|
26
|
+
const found = attrs.find(a => a.key === key);
|
|
27
|
+
if (!found) return null;
|
|
28
|
+
const v = found.value;
|
|
29
|
+
// Strip surrounding JSON quotes if present
|
|
30
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
31
|
+
try { return JSON.parse(v); } catch { /* fall through */ }
|
|
32
|
+
}
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Event Decoder ───────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decode a fee-grant event from a TX event list.
|
|
40
|
+
* Returns null if the event doesn't match a grant or revoke action.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ type: string, attributes: ReadonlyArray<{key:string, value:string}> }} ev
|
|
43
|
+
* @param {string} hash - TX hash (hex)
|
|
44
|
+
* @param {number} height - Block height
|
|
45
|
+
* @returns {{ action: 'grant'|'revoke', granter: string, grantee: string, txHash: string, height: number }|null}
|
|
46
|
+
*/
|
|
47
|
+
export function decodeFeeGrantEvent(ev, hash, height) {
|
|
48
|
+
// cosmos.feegrant events emit under 'cosmos.feegrant.v1beta1.EventGrantAllowance'
|
|
49
|
+
// or 'cosmos.feegrant.v1beta1.EventRevokeAllowance', or under 'message' with
|
|
50
|
+
// action='/cosmos.feegrant.v1beta1.MsgGrantAllowance'
|
|
51
|
+
const type = ev.type;
|
|
52
|
+
const attrs = ev.attributes;
|
|
53
|
+
|
|
54
|
+
let action = null;
|
|
55
|
+
if (type === 'cosmos.feegrant.v1beta1.EventGrantAllowance' ||
|
|
56
|
+
type === 'grant_allowance') {
|
|
57
|
+
action = 'grant';
|
|
58
|
+
} else if (type === 'cosmos.feegrant.v1beta1.EventRevokeAllowance' ||
|
|
59
|
+
type === 'revoke_allowance') {
|
|
60
|
+
action = 'revoke';
|
|
61
|
+
} else if (type === 'message') {
|
|
62
|
+
const msgAction = attr(attrs, 'action');
|
|
63
|
+
if (msgAction === '/cosmos.feegrant.v1beta1.MsgGrantAllowance') action = 'grant';
|
|
64
|
+
else if (msgAction === '/cosmos.feegrant.v1beta1.MsgRevokeAllowance') action = 'revoke';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!action) return null;
|
|
68
|
+
|
|
69
|
+
const granter = attr(attrs, 'granter');
|
|
70
|
+
const grantee = attr(attrs, 'grantee');
|
|
71
|
+
if (!granter || !grantee) return null;
|
|
72
|
+
|
|
73
|
+
return { action, granter, grantee, txHash: hash, height };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Main Query ──────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Query fee grant history (grants and revokes) for an address.
|
|
80
|
+
*
|
|
81
|
+
* Searches both as granter and grantee by default. Set opts.role to
|
|
82
|
+
* 'granter' or 'grantee' to narrow the search.
|
|
83
|
+
*
|
|
84
|
+
* @param {import('@cosmjs/tendermint-rpc').Tendermint37Client} tmClient - From createRpcQueryClient().tmClient
|
|
85
|
+
* @param {string} address - Address to search (sent1...)
|
|
86
|
+
* @param {object} [opts]
|
|
87
|
+
* @param {'granter'|'grantee'|'both'} [opts.role='both'] - Which role to search
|
|
88
|
+
* @param {number} [opts.perPage=50] - Results per page
|
|
89
|
+
* @param {'asc'|'desc'} [opts.order='desc'] - Sort order (newest first by default)
|
|
90
|
+
* @returns {Promise<Array<{ action: 'grant'|'revoke', granter: string, grantee: string, txHash: string, height: number }>>}
|
|
91
|
+
*/
|
|
92
|
+
export async function queryFeeGrantHistory(tmClient, address, opts = {}) {
|
|
93
|
+
if (!tmClient) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'tmClient is required');
|
|
94
|
+
if (!address) throw new ValidationError(ErrorCodes.INVALID_OPTIONS, 'address is required');
|
|
95
|
+
|
|
96
|
+
const { role = 'both', perPage = 50, order = 'desc' } = opts;
|
|
97
|
+
|
|
98
|
+
const queries = [];
|
|
99
|
+
if (role === 'both' || role === 'granter') {
|
|
100
|
+
queries.push(`message.action='/cosmos.feegrant.v1beta1.MsgGrantAllowance' AND message.sender='${address}'`);
|
|
101
|
+
queries.push(`message.action='/cosmos.feegrant.v1beta1.MsgRevokeAllowance' AND message.sender='${address}'`);
|
|
102
|
+
}
|
|
103
|
+
if (role === 'both' || role === 'grantee') {
|
|
104
|
+
queries.push(`message.action='/cosmos.feegrant.v1beta1.MsgGrantAllowance' AND cosmos.feegrant.v1beta1.EventGrantAllowance.grantee='${address}'`);
|
|
105
|
+
queries.push(`message.action='/cosmos.feegrant.v1beta1.MsgRevokeAllowance' AND cosmos.feegrant.v1beta1.EventRevokeAllowance.grantee='${address}'`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const results = [];
|
|
110
|
+
|
|
111
|
+
for (const query of queries) {
|
|
112
|
+
try {
|
|
113
|
+
const resp = await tmClient.txSearchAll({ query, per_page: perPage, order_by: order });
|
|
114
|
+
for (const tx of resp.txs) {
|
|
115
|
+
const hash = Buffer.from(tx.hash).toString('hex').toUpperCase();
|
|
116
|
+
if (seen.has(hash)) continue;
|
|
117
|
+
|
|
118
|
+
for (const ev of tx.result.events) {
|
|
119
|
+
const decoded = decodeFeeGrantEvent(ev, hash, tx.height);
|
|
120
|
+
if (decoded) {
|
|
121
|
+
results.push(decoded);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
seen.add(hash);
|
|
125
|
+
}
|
|
126
|
+
} catch { /* query may fail if node doesn't index — skip silently */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Sort by height descending (newest first)
|
|
130
|
+
results.sort((a, b) => b.height - a.height);
|
|
131
|
+
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel SDK — Plan Ownership Pre-Flight
|
|
3
|
+
*
|
|
4
|
+
* Verifies the calling wallet owns a plan before broadcasting any mutating TX.
|
|
5
|
+
* Saves ~0.005 P2P per rejected broadcast and gives a clean error instead of
|
|
6
|
+
* a chain rejection.
|
|
7
|
+
*
|
|
8
|
+
* The sent <-> sentprov bech32 conversion is a footgun every plan-manager
|
|
9
|
+
* consumer hits independently. This module is the single source of truth.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* import { assertPlanOwnership, PlanOwnershipError } from './operator/plan-ownership.js';
|
|
13
|
+
* const plan = await assertPlanOwnership({ planId, walletAddr, client });
|
|
14
|
+
* // throws PlanOwnershipError if not owner; returns plan object if ok
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fromBech32, toBech32 } from '@cosmjs/encoding';
|
|
18
|
+
import { rpcQueryPlan } from '../chain/rpc.js';
|
|
19
|
+
import { lcdQuery } from '../chain/lcd.js';
|
|
20
|
+
|
|
21
|
+
// ─── Error Type ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export class PlanOwnershipError extends Error {
|
|
24
|
+
constructor({ planId, expected, actual }) {
|
|
25
|
+
super(`Plan ${planId} is owned by ${expected}, not ${actual}`);
|
|
26
|
+
this.code = 'PLAN_OWNERSHIP_ERROR';
|
|
27
|
+
this.planId = planId;
|
|
28
|
+
this.expected = expected;
|
|
29
|
+
this.actual = actual;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Address Conversion ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Convert a sent1... wallet address to its sentprov... equivalent.
|
|
37
|
+
* Plan `provider_address` fields use sentprov prefix; wallet addresses use sent.
|
|
38
|
+
* The raw bytes are identical — only the prefix differs.
|
|
39
|
+
*/
|
|
40
|
+
export function walletToProviderAddr(sentAddr) {
|
|
41
|
+
return toBech32('sentprov', fromBech32(sentAddr).data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Plan Fetcher (RPC-first) ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
async function fetchPlan(planId, client, preferRpc) {
|
|
47
|
+
if (preferRpc && client?.tmClient) {
|
|
48
|
+
try {
|
|
49
|
+
return await rpcQueryPlan(client.tmClient, planId);
|
|
50
|
+
} catch (_) {
|
|
51
|
+
// fall through to LCD
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// LCD fallback
|
|
55
|
+
const res = await lcdQuery(`/sentinel/plan/v3/plans/${planId}`);
|
|
56
|
+
return res?.plan ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── assertPlanOwnership ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Assert that `walletAddr` owns plan `planId`. Throws `PlanOwnershipError`
|
|
63
|
+
* if the plan exists but belongs to a different provider. Throws a generic
|
|
64
|
+
* Error if the plan does not exist.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {number|string} opts.planId - Plan ID to check
|
|
68
|
+
* @param {string} opts.walletAddr - Caller's sent1... wallet address
|
|
69
|
+
* @param {object} opts.client - SentinelClient (needs .tmClient for RPC)
|
|
70
|
+
* @param {boolean} [opts.preferRpc=true] - Try RPC first, fall back to LCD
|
|
71
|
+
* @returns {Promise<object>} Plan object if ownership confirmed
|
|
72
|
+
* @throws {PlanOwnershipError} If a different provider owns the plan
|
|
73
|
+
* @throws {Error} If the plan is not found
|
|
74
|
+
*/
|
|
75
|
+
export async function assertPlanOwnership({ planId, walletAddr, client, preferRpc = true }) {
|
|
76
|
+
const plan = await fetchPlan(planId, client, preferRpc);
|
|
77
|
+
if (!plan) throw new Error(`Plan ${planId} not found`);
|
|
78
|
+
|
|
79
|
+
const walletAsProv = walletToProviderAddr(walletAddr);
|
|
80
|
+
if (plan.provider_address !== walletAsProv) {
|
|
81
|
+
throw new PlanOwnershipError({
|
|
82
|
+
planId,
|
|
83
|
+
expected: plan.provider_address,
|
|
84
|
+
actual: walletAsProv,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return plan;
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blue-js-sdk",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.2",
|
|
4
4
|
"description": "Decentralized VPN SDK for the Sentinel P2P bandwidth network. WireGuard + V2Ray tunnels, Cosmos blockchain, 900+ nodes. Tested on Windows. macOS/Linux support included but untested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"*.js",
|
|
35
35
|
"types/",
|
|
36
|
+
"auth/",
|
|
36
37
|
"chain/",
|
|
37
38
|
"connection/",
|
|
38
39
|
"protocol/",
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
"speedtest/",
|
|
43
44
|
"state/",
|
|
44
45
|
"client/",
|
|
46
|
+
"operator/",
|
|
45
47
|
"cli/",
|
|
46
48
|
"config/",
|
|
47
49
|
"errors/",
|