diff --git a/client.js b/client.js index 072150f..80589b8 100644 --- a/client.js +++ b/client.js @@ -50,6 +50,10 @@ export class SentinelClient extends EventEmitter { * @param {string} opts.rpcUrl - Default RPC URL (overridable per-call) * @param {string} opts.lcdUrl - Default LCD URL (overridable per-call) * @param {string} opts.mnemonic - Default mnemonic (overridable per-call) + * @param {object} opts.signer - Pre-built cosmjs OfflineDirectSigner (e.g. from + * PrivyCosmosSigner.fromRawSign). When provided, takes precedence over `mnemonic` + * for queries and broadcasts. NOTE: VPN connect/disconnect (tunnel handshake) + * currently still requires a mnemonic — see docs/PRIVY-INTEGRATION.md. * @param {string} opts.v2rayExePath - Default V2Ray binary path * @param {function} opts.logger - Logger function (default: console.log). Set to null to suppress. * @param {'tofu'|'none'} opts.tlsTrust - TLS trust mode (default: 'tofu') @@ -89,6 +93,21 @@ export class SentinelClient extends EventEmitter { return merged; } + /** + * Throw a helpful error if a connect path is invoked without a mnemonic. + * The WireGuard/V2Ray handshake currently signs locally with the cosmos privkey, + * so a raw-sign-only signer (e.g. Privy custody) cannot complete the tunnel + * handshake. Queries and broadcasts work without a mnemonic. + */ + _requireMnemonicForTunnel(merged) { + if (typeof merged.mnemonic === 'string' && merged.mnemonic.trim().length > 0) return; + throw new SentinelError(ErrorCodes.INVALID_MNEMONIC, + 'VPN connect/disconnect requires a mnemonic. A signer-only client (e.g. ' + + 'Privy raw-sign Mode B) can broadcast TXs and query chain state, but the ' + + 'tunnel handshake signs with the raw secp256k1 privkey. Pass `mnemonic` to ' + + 'the constructor or to this call. See docs/PRIVY-INTEGRATION.md.'); + } + // ─── Connection ────────────────────────────────────────────────────────── /** @@ -98,6 +117,7 @@ export class SentinelClient extends EventEmitter { */ async connect(opts = {}) { const merged = this._mergeOpts(opts); + this._requireMnemonicForTunnel(merged); this._connection = await connectDirect(merged); return this._connection; } @@ -110,6 +130,7 @@ export class SentinelClient extends EventEmitter { */ async autoConnect(opts = {}) { const merged = this._mergeOpts(opts); + this._requireMnemonicForTunnel(merged); this._connection = await connectAuto(merged); return this._connection; } @@ -121,6 +142,7 @@ export class SentinelClient extends EventEmitter { */ async connectPlan(opts = {}) { const merged = this._mergeOpts(opts); + this._requireMnemonicForTunnel(merged); this._connection = await connectViaPlan(merged); return this._connection; } @@ -209,16 +231,50 @@ export class SentinelClient extends EventEmitter { // ─── Wallet & Chain ────────────────────────────────────────────────────── /** - * Create or return cached wallet from mnemonic. - * @param {string} mnemonic - Override mnemonic (or uses instance default) + * Create or return cached wallet/signer. + * + * Resolution order: + * 1. `mnemonic` arg (per-call override) → derive a DirectSecp256k1HdWallet + * 2. `this._defaults.signer` (constructor-supplied OfflineDirectSigner) → use as-is + * 3. `this._defaults.mnemonic` → derive once, cache by mnemonic SHA + * + * Returned shape: `{ wallet, account }` — `wallet` is a cosmjs OfflineDirectSigner + * (DirectSecp256k1HdWallet OR a PrivyRawSignDirectSigner OR any equivalent), and + * `account` is the first entry from `wallet.getAccounts()`. + * + * @param {string} [mnemonic] - Optional per-call mnemonic override */ async getWallet(mnemonic) { - const m = mnemonic || this._defaults.mnemonic; - if (!m) throw new SentinelError(ErrorCodes.INVALID_MNEMONIC, 'No mnemonic provided'); - // Invalidate cache if mnemonic changed + // Per-call mnemonic always wins (override path). + if (mnemonic) { + if (this._wallet && this._walletMnemonic !== mnemonic) { + this._wallet = null; + this._client = null; + } + if (this._wallet) return this._wallet; + this._wallet = await createWallet(mnemonic); + this._walletMnemonic = mnemonic; + return this._wallet; + } + // Constructor-supplied signer (Privy raw-sign, Keplr offline signer, etc.). + if (this._defaults.signer) { + if (this._wallet) return this._wallet; + const accounts = await this._defaults.signer.getAccounts(); + if (!accounts || accounts.length === 0) { + throw new SentinelError(ErrorCodes.INVALID_OPTIONS, + 'signer.getAccounts() returned no accounts'); + } + this._wallet = { wallet: this._defaults.signer, account: accounts[0] }; + this._walletMnemonic = null; + return this._wallet; + } + // Constructor-supplied mnemonic (the original path). + const m = this._defaults.mnemonic; + if (!m) throw new SentinelError(ErrorCodes.INVALID_MNEMONIC, + 'No mnemonic or signer provided. Pass `mnemonic` or `signer` to the SentinelClient constructor.'); if (this._wallet && this._walletMnemonic !== m) { this._wallet = null; - this._client = null; // client depends on wallet + this._client = null; } if (this._wallet) return this._wallet; this._wallet = await createWallet(m); diff --git a/docs/PRIVY-INTEGRATION.md b/docs/PRIVY-INTEGRATION.md index 9d41d89..ba54bdc 100644 --- a/docs/PRIVY-INTEGRATION.md +++ b/docs/PRIVY-INTEGRATION.md @@ -70,10 +70,50 @@ This is useful when the consumer wants to display the user's `sent1...` address The Mode A return value IS a `DirectSecp256k1HdWallet`. The Mode B return value is an `OfflineDirectSigner` — `getAccounts()` + `signDirect(signerAddress, signDoc)`. Either can be passed straight to `SigningStargateClient.connectWithSigner` and to every Sentinel SDK helper that accepts a `wallet`: -- `connect()`, `connectDirect()`, `connectViaPlan()` — VPN session start -- `disconnect()` — session end - `broadcast()`, `broadcastWithFeeGrant()`, `createSafeBroadcaster()` — TX broadcast - Operator helpers: `autoLeaseNode()`, `batchLeaseNodes()`, `batchRevokeFeeGrants()`, etc. +- `SentinelClient` query surface — `getBalance()`, `getClient()`, `listNodes()`, etc. + +### Tunnel connect/disconnect — Mode A only (today) + +VPN session start (`connect()`, `autoConnect()`, `connectPlan()`) and matching teardown perform a WireGuard/V2Ray handshake with the node. The handshake protocol requires the SDK to sign a small payload with the **raw** secp256k1 privkey **locally**, before any chain TX. That privkey is not available in Mode B — Privy's raw-sign endpoint signs digests but does not export the key. + +In short: + +| Operation | Mode A (mnemonic) | Mode B (rawSign) | +|---|---|---| +| `getBalance()`, `listNodes()`, queries | works | works | +| `broadcast()`, `broadcastWithFeeGrant()` | works | works | +| Operator helpers (`autoLeaseNode`, batch*) | works | works | +| `connect()`, `autoConnect()`, `connectPlan()` | works | **throws** with "VPN connect/disconnect requires a mnemonic" | + +A signer-only `SentinelClient` will throw a helpful error from the connect methods rather than failing deep inside the handshake. Lifting this restriction requires either (a) refactoring the handshake to call out to `signRawSecp256k1`, or (b) Privy exposing a "raw secp256k1 sign" endpoint shaped like the cosmjs `Secp256k1.createSignature` signature already accepted in Mode B — both viable, neither in this PR. + +## Using `SentinelClient` with Privy + +```js +import { SentinelClient, PrivyCosmosSigner } from 'blue-js-sdk'; + +// Mode A — full feature set +const signer = await PrivyCosmosSigner.fromMnemonic({ mnemonic: privyExportedSeed }); +const client = new SentinelClient({ + signer, + rpcUrl: 'https://rpc.sentinel.co', + // mnemonic still required for VPN connect — see table above + mnemonic: privyExportedSeed, +}); +const balance = await client.getBalance(); // works +const conn = await client.autoConnect(); // works (uses mnemonic) + +// Mode B — custody-preserving (queries + broadcasts only) +const custodySigner = await PrivyCosmosSigner.fromRawSign({ + pubkey: privyDerivedCompressedPubkey, + signRawSecp256k1: async (digest32) => privy.signRawHash({ hash: digest32, curve: 'secp256k1' }), +}); +const queryClient = new SentinelClient({ signer: custodySigner, rpcUrl: 'https://rpc.sentinel.co' }); +await queryClient.getBalance(); // works — queries Privy for the address +// await queryClient.connect(...); // throws: requires a mnemonic +``` ## Unified factory @@ -109,3 +149,11 @@ const signer = await createPrivyCosmosSigner({ - `signerAddress` mismatch is rejected - Unified factory routes correctly and rejects unknown modes - Static facade delegates to the underlying functions + +`test/privy-client-integration.test.mjs` — 12 assertions covering: + +- `SentinelClient({ signer })` — `getWallet()` returns the supplied signer + first account, no mnemonic required +- `SentinelClient({ mnemonic })` — backwards-compatible path still works +- `SentinelClient({})` — `getWallet()` throws with a helpful "mnemonic or signer" message +- `SentinelClient({ signer })` — `connect()`, `autoConnect()`, `connectPlan()` all reject with "requires a mnemonic" pointing to this doc +- Address parity between `PrivyCosmosSigner(mnemonic)` and `SentinelClient(mnemonic)` diff --git a/test/privy-client-integration.test.mjs b/test/privy-client-integration.test.mjs new file mode 100644 index 0000000..7ebc6f0 --- /dev/null +++ b/test/privy-client-integration.test.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * SentinelClient + Privy adapter integration tests. + * + * Covers: + * 1. SentinelClient with `signer` (Mode B / Privy raw-sign) — getWallet() + * returns the signer + first account, no mnemonic required. + * 2. Same SentinelClient — getAccounts()[0].address matches the address that + * Mode A (mnemonic) derives from the same seed. + * 3. SentinelClient with `mnemonic` (the original path) still works. + * 4. SentinelClient with `signer` rejects connect()/autoConnect()/connectPlan() + * with the expected "VPN connect/disconnect requires a mnemonic" error, + * because the WireGuard/V2Ray handshake signs with the raw cosmos privkey. + * + * No network — pure offline assertions on the wiring. + * + * Run: node test/privy-client-integration.test.mjs + */ + +import { + SentinelClient, + PrivyRawSignDirectSigner, + privyCosmosSignerFromMnemonic, + createWallet, +} from '../index.js'; +import { + Bip39, EnglishMnemonic, Slip10, Slip10Curve, Secp256k1, +} from '@cosmjs/crypto'; +import { makeCosmoshubPath } from '@cosmjs/amino'; + +let pass = 0, fail = 0; +const failures = []; +function assert(cond, name) { + if (cond) { pass++; console.log(` PASS: ${name}`); } + else { fail++; failures.push(name); console.log(` FAIL: ${name}`); } +} + +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +console.log('SentinelClient + Privy adapter integration tests\n'); + +// Re-derive the privkey + compressed pubkey locally (this is what Privy holds). +const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(MNEMONIC)); +const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, makeCosmoshubPath(0)); +const keypair = await Secp256k1.makeKeypair(privkey); +const compressedPubkey = Secp256k1.compressPubkey(keypair.pubkey); + +async function fakePrivyRawSign(digest32) { + const sig = await Secp256k1.createSignature(digest32, privkey); + const out = new Uint8Array(64); + out.set(sig.r(32), 0); + out.set(sig.s(32), 32); + return out; +} + +const { account: refAccount } = await createWallet(MNEMONIC); + +// ─── 1. SentinelClient with signer (no mnemonic) ──────────────────────────── + +console.log('1. SentinelClient with `signer` (no mnemonic)...'); +const privySigner = new PrivyRawSignDirectSigner({ + pubkey: compressedPubkey, + signRawSecp256k1: fakePrivyRawSign, +}); +const clientB = new SentinelClient({ signer: privySigner }); +const wB = await clientB.getWallet(); +assert(wB && wB.account && typeof wB.account.address === 'string', + 'getWallet() returns { wallet, account } shape'); +assert(wB.wallet === privySigner, + 'getWallet().wallet IS the supplied signer (not re-wrapped)'); +assert(wB.account.address === refAccount.address, + `signer address matches createWallet() address (${wB.account.address})`); +assert(wB.account.address.startsWith('sent1'), + 'address has sent1 prefix'); + +// Cached on second call. +const wB2 = await clientB.getWallet(); +assert(wB2 === wB, 'getWallet() returns the same object on second call'); +clientB.destroy(); + +// ─── 2. SentinelClient with mnemonic (backwards compat) ───────────────────── + +console.log('\n2. SentinelClient with `mnemonic` (backwards compat)...'); +const clientA = new SentinelClient({ mnemonic: MNEMONIC }); +const wA = await clientA.getWallet(); +assert(wA && wA.account && wA.account.address === refAccount.address, + 'mnemonic-mode address matches refAccount'); +clientA.destroy(); + +// ─── 3. SentinelClient with neither mnemonic nor signer throws helpfully ──── + +console.log('\n3. SentinelClient with no auth throws helpfully...'); +const clientNone = new SentinelClient({}); +let threwNone = false; +let noneMsg = ''; +try { await clientNone.getWallet(); } +catch (err) { threwNone = true; noneMsg = err?.message || ''; } +assert(threwNone && noneMsg.includes('mnemonic or signer'), + 'getWallet() throws with helpful message when neither is provided'); +clientNone.destroy(); + +// ─── 4. signer-only mode rejects connect()/autoConnect()/connectPlan() ────── + +console.log('\n4. signer-only mode rejects tunnel connect paths...'); +const clientB2 = new SentinelClient({ signer: privySigner }); + +let threwConnect = false, msgConnect = ''; +try { await clientB2.connect({ nodeAddress: 'sentnode1xxx' }); } +catch (err) { threwConnect = true; msgConnect = err?.message || ''; } +assert(threwConnect && msgConnect.includes('requires a mnemonic'), + 'connect() rejects signer-only mode with mnemonic-required error'); +assert(msgConnect.includes('PRIVY-INTEGRATION'), + 'error message points to docs/PRIVY-INTEGRATION.md'); + +let threwAuto = false, msgAuto = ''; +try { await clientB2.autoConnect({}); } +catch (err) { threwAuto = true; msgAuto = err?.message || ''; } +assert(threwAuto && msgAuto.includes('requires a mnemonic'), + 'autoConnect() rejects signer-only mode'); + +let threwPlan = false, msgPlan = ''; +try { await clientB2.connectPlan({ planId: 1n }); } +catch (err) { threwPlan = true; msgPlan = err?.message || ''; } +assert(threwPlan && msgPlan.includes('requires a mnemonic'), + 'connectPlan() rejects signer-only mode'); + +clientB2.destroy(); + +// ─── 5. Mnemonic-mode SentinelClient address matches Mode A signer address ── + +console.log('\n5. Address parity SentinelClient(mnemonic) vs PrivyCosmosSigner(mnemonic)...'); +const modeASigner = await privyCosmosSignerFromMnemonic({ mnemonic: MNEMONIC }); +const [modeAAcc] = await modeASigner.getAccounts(); +const clientA2 = new SentinelClient({ mnemonic: MNEMONIC }); +const { account: clientAAcc } = await clientA2.getWallet(); +assert(modeAAcc.address === clientAAcc.address, + 'PrivyCosmosSigner(mnemonic) and SentinelClient(mnemonic) derive same address'); +clientA2.destroy(); + +// ─── Summary ──────────────────────────────────────────────────────────────── + +console.log('\n' + '='.repeat(60)); +console.log(`Results: ${pass} passed, ${fail} failed`); +if (fail > 0) { + console.log('\nFailures:'); + for (const f of failures) console.log(' - ' + f); + process.exit(1); +} +console.log('All SentinelClient + Privy integration tests passed.');