blue-js-sdk 2.7.1 → 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 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
+ }
@@ -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.1",
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/",