Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────

/**
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
52 changes: 50 additions & 2 deletions docs/PRIVY-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)`
149 changes: 149 additions & 0 deletions test/privy-client-integration.test.mjs
Original file line number Diff line number Diff line change
@@ -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.');
Loading