diff --git a/auth/privy-cosmos-signer.js b/auth/privy-cosmos-signer.js new file mode 100644 index 0000000..625f584 --- /dev/null +++ b/auth/privy-cosmos-signer.js @@ -0,0 +1,298 @@ +/** + * Sentinel SDK — Privy → Cosmos Signer Adapter + * + * Bridges a Privy embedded wallet (EVM/Solana-native) to a Sentinel Cosmos + * signer. The result satisfies cosmjs `OfflineDirectSigner`, so it can be + * passed straight to `SigningStargateClient.connectWithSigner` and to every + * Sentinel SDK helper that takes a `wallet`. + * + * Two strategies are supported, picked by the `mode` field on the input. + * + * ─── Mode A: 'mnemonic' (seed-import) ────────────────────────────────────── + * The consumer triggers Privy's `exportWallet()` once, captures the seed + * phrase the user reveals, and hands it to this adapter. The adapter derives + * a Cosmos secp256k1 key on Sentinel's HD path (cosmoshub-style, coinType + * 118) and wraps it in `DirectSecp256k1HdWallet`. Same trust model as a + * normal mnemonic wallet — the seed leaves Privy's secure enclave. + * + * Use this when you need full broadcast capability (sessions, payments, + * fee-grants) and your UX can prompt the user to export once. + * + * ─── Mode B: 'rawSign' (custody-preserving) ──────────────────────────────── + * The seed never leaves Privy. The consumer supplies: + * - `pubkey`: the compressed secp256k1 pubkey (33 bytes) Privy derived for + * this user on the Cosmos `m/44'/118'/0'/0/0` path. Privy exposes raw + * signing for embedded wallets; deriving the pubkey from the same path + * yields the same `sent1...` address Mode A would produce. + * - `signRawSecp256k1(digest32)`: an async function that asks Privy to + * produce a 64-byte (r||s) signature over the supplied 32-byte digest, + * using the same key the pubkey came from. The adapter computes the + * digest of the cosmjs `SignDoc` itself, so Privy only sees a hash. + * + * Use this when you must keep custody inside Privy. Note that + * `signRawSecp256k1` MUST return a *normalized low-S* signature — cosmjs + * rejects high-S sigs. The adapter normalizes defensively. + * + * ─── Usage ───────────────────────────────────────────────────────────────── + * + * // Mode A + * const signer = await PrivyCosmosSigner.fromMnemonic({ + * mnemonic: privyExportedSeed, + * prefix: 'sent', + * }); + * + * // Mode B + * const signer = await PrivyCosmosSigner.fromRawSign({ + * pubkey: privyDerivedCompressedPubkey, // Uint8Array(33) + * signRawSecp256k1: async (digest32) => { + * const sig = await privy.signRawHash({ hash: digest32, curve: 'secp256k1' }); + * return sig; // Uint8Array(64), r||s + * }, + * prefix: 'sent', + * }); + * + * const [account] = await signer.getAccounts(); + * // account.address === 'sent1...' + * const client = await SigningStargateClient.connectWithSigner(rpc, signer, ...); + */ + +import { + Bip39, + EnglishMnemonic, + Slip10, + Slip10Curve, + Secp256k1, + sha256, + ripemd160, +} from '@cosmjs/crypto'; +import { makeCosmoshubPath } from '@cosmjs/amino'; +import { DirectSecp256k1HdWallet, makeSignDoc } from '@cosmjs/proto-signing'; +import { fromBech32, toBech32 } from '@cosmjs/encoding'; +import { ValidationError, ErrorCodes } from '../errors/index.js'; + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +function assertPrefix(prefix) { + if (typeof prefix !== 'string' || prefix.length === 0) { + throw new ValidationError(ErrorCodes.INVALID_OPTIONS, + 'PrivyCosmosSigner: prefix must be a non-empty string (e.g. "sent")', + { prefix }); + } +} + +// secp256k1 group order n. Signatures with s > n/2 are non-canonical and +// rejected by Cosmos chains since cosmos-sdk v0.42. Privy's raw-sign path is +// not guaranteed to return low-S form, so we normalize defensively. +const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); +const SECP256K1_HALF_N = SECP256K1_N >> 1n; + +function bytesToBigInt(bytes) { + let n = 0n; + for (const b of bytes) n = (n << 8n) | BigInt(b); + return n; +} + +function bigIntTo32Bytes(n) { + const out = new Uint8Array(32); + for (let i = 31; i >= 0; i--) { + out[i] = Number(n & 0xffn); + n >>= 8n; + } + return out; +} + +function normalizeLowS(rawSig) { + const r = rawSig.slice(0, 32); + const sBytes = rawSig.slice(32, 64); + const s = bytesToBigInt(sBytes); + if (s <= SECP256K1_HALF_N) return rawSig; + const flipped = SECP256K1_N - s; + const out = new Uint8Array(64); + out.set(r, 0); + out.set(bigIntTo32Bytes(flipped), 32); + return out; +} + +function pubkeyToBech32Address(compressedPubkey, prefix) { + if (!(compressedPubkey instanceof Uint8Array) || compressedPubkey.length !== 33) { + throw new ValidationError(ErrorCodes.INVALID_OPTIONS, + 'PrivyCosmosSigner: pubkey must be a 33-byte compressed secp256k1 Uint8Array', + { length: compressedPubkey?.length }); + } + const data = ripemd160(sha256(compressedPubkey)); + return toBech32(prefix, data); +} + +// ─── Mode A: seed-import via Privy exportWallet ───────────────────────────── + +/** + * Build a signer from a mnemonic the user just exported from Privy. + * + * Internally this is `DirectSecp256k1HdWallet` with Sentinel's prefix — i.e. + * the same key + address the consumer would get from `createWallet()` if + * they typed the mnemonic in directly. The wrapper exists so a consumer can + * write the same code path regardless of which Privy mode they're in. + * + * @param {object} opts + * @param {string} opts.mnemonic + * @param {string} [opts.prefix='sent'] + * @returns {Promise} A cosmjs OfflineDirectSigner + */ +export async function privyCosmosSignerFromMnemonic({ mnemonic, prefix = 'sent' } = {}) { + assertPrefix(prefix); + if (typeof mnemonic !== 'string' || mnemonic.trim().split(/\s+/).length < 12) { + throw new ValidationError(ErrorCodes.INVALID_MNEMONIC, + 'privyCosmosSignerFromMnemonic: mnemonic must be a 12+ word BIP39 string', + { wordCount: typeof mnemonic === 'string' ? mnemonic.trim().split(/\s+/).length : 0 }); + } + return DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix }); +} + +/** + * Convenience: derive the compressed secp256k1 pubkey + address from a + * mnemonic on Sentinel's HD path. Useful for pre-computing what the + * `sent1...` address WILL be in Mode B before plumbing the raw-sign callback. + * + * @param {string} mnemonic + * @param {string} [prefix='sent'] + * @returns {Promise<{ pubkey: Uint8Array, address: string }>} + */ +export async function deriveCosmosPubkeyFromMnemonic(mnemonic, prefix = 'sent') { + assertPrefix(prefix); + const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(mnemonic)); + const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, makeCosmoshubPath(0)); + const { pubkey } = await Secp256k1.makeKeypair(privkey); + const compressed = Secp256k1.compressPubkey(pubkey); + return { pubkey: compressed, address: pubkeyToBech32Address(compressed, prefix) }; +} + +// ─── Mode B: raw-sign (Privy keeps custody) ───────────────────────────────── + +/** + * `OfflineDirectSigner` that delegates the actual ECDSA op to Privy. The + * private key never leaves Privy's enclave; we hash the SignDoc locally and + * ship the 32-byte digest to the supplied callback. + * + * Conforms to cosmjs `OfflineDirectSigner`: + * - `getAccounts()` → `[{ address, algo: 'secp256k1', pubkey }]` + * - `signDirect(signerAddress, signDoc)` → `{ signed, signature }` + */ +export class PrivyRawSignDirectSigner { + /** + * @param {object} opts + * @param {Uint8Array} opts.pubkey - 33-byte compressed secp256k1 pubkey + * @param {(digest: Uint8Array) => Promise} opts.signRawSecp256k1 + * Returns a 64-byte (r||s) signature over `digest`. MUST be low-S + * normalized; the adapter re-normalizes defensively. + * @param {string} [opts.prefix='sent'] + */ + constructor({ pubkey, signRawSecp256k1, prefix = 'sent' }) { + assertPrefix(prefix); + if (typeof signRawSecp256k1 !== 'function') { + throw new ValidationError(ErrorCodes.INVALID_OPTIONS, + 'PrivyRawSignDirectSigner: signRawSecp256k1 must be a function', + {}); + } + this._pubkey = pubkey; + this._sign = signRawSecp256k1; + this._prefix = prefix; + this._address = pubkeyToBech32Address(pubkey, prefix); + } + + async getAccounts() { + return [{ + address: this._address, + algo: 'secp256k1', + pubkey: this._pubkey, + }]; + } + + /** + * @param {string} signerAddress + * @param {import('@cosmjs/proto-signing').SignDoc} signDoc + */ + async signDirect(signerAddress, signDoc) { + if (signerAddress !== this._address) { + throw new ValidationError(ErrorCodes.INVALID_OPTIONS, + `PrivyRawSignDirectSigner: signerAddress mismatch (got ${signerAddress}, signer holds ${this._address})`, + { expected: this._address, got: signerAddress }); + } + // Re-encode the SignDoc the same way cosmjs does, then SHA-256 it. + // Importing the raw protobuf encoder via makeSignDoc → makeSignBytes + // would also work, but doing it inline keeps the dep surface tight. + const { makeSignBytes } = await import('@cosmjs/proto-signing'); + const signBytes = makeSignBytes(signDoc); + const digest = sha256(signBytes); + + const rawSig = await this._sign(digest); + if (!(rawSig instanceof Uint8Array) || rawSig.length !== 64) { + throw new ValidationError(ErrorCodes.INVALID_OPTIONS, + 'PrivyRawSignDirectSigner: signRawSecp256k1 must return a 64-byte (r||s) Uint8Array', + { length: rawSig?.length }); + } + + // Normalize to low-S so chain validators accept it. cosmjs's + // Secp256k1Signature.fromFixedLength + Secp256k1.trimRecoveryByte path + // is internal; instead we parse r/s as bigints and flip s if it sits in + // the upper half of the curve order. + const normalized = normalizeLowS(rawSig); + + return { + signed: signDoc, + signature: { + pub_key: { + type: 'tendermint/PubKeySecp256k1', + value: Buffer.from(this._pubkey).toString('base64'), + }, + signature: Buffer.from(normalized).toString('base64'), + }, + }; + } +} + +/** + * Build a Mode B signer. + * + * @param {object} opts + * @param {Uint8Array} opts.pubkey + * @param {(digest: Uint8Array) => Promise} opts.signRawSecp256k1 + * @param {string} [opts.prefix='sent'] + * @returns {Promise} + */ +export async function privyCosmosSignerFromRawSign(opts) { + return new PrivyRawSignDirectSigner(opts); +} + +// ─── Unified factory ──────────────────────────────────────────────────────── + +/** + * Single entry point picking the right strategy by `mode`. + * + * @param {{ mode: 'mnemonic', mnemonic: string, prefix?: string } + * | { mode: 'rawSign', pubkey: Uint8Array, + * signRawSecp256k1: (digest: Uint8Array) => Promise, + * prefix?: string }} opts + * @returns {Promise} + */ +export async function createPrivyCosmosSigner(opts = {}) { + if (opts.mode === 'mnemonic') { + return privyCosmosSignerFromMnemonic(opts); + } + if (opts.mode === 'rawSign') { + return privyCosmosSignerFromRawSign(opts); + } + throw new ValidationError(ErrorCodes.INVALID_OPTIONS, + `createPrivyCosmosSigner: unknown mode "${opts.mode}" — expected "mnemonic" or "rawSign"`, + { mode: opts.mode }); +} + +// ─── Static facade ────────────────────────────────────────────────────────── + +export class PrivyCosmosSigner { + static fromMnemonic(opts) { return privyCosmosSignerFromMnemonic(opts); } + static fromRawSign(opts) { return privyCosmosSignerFromRawSign(opts); } + static create(opts) { return createPrivyCosmosSigner(opts); } + static derivePubkeyFromMnemonic(mnemonic, prefix) { + return deriveCosmosPubkeyFromMnemonic(mnemonic, prefix); + } +} diff --git a/docs/PRIVY-INTEGRATION.md b/docs/PRIVY-INTEGRATION.md new file mode 100644 index 0000000..9d41d89 --- /dev/null +++ b/docs/PRIVY-INTEGRATION.md @@ -0,0 +1,111 @@ +# Privy Integration + +Privy provides embedded EVM/Solana wallets but has no native Cosmos signer. This SDK ships an adapter that bridges a Privy-held key to a cosmjs-compatible Cosmos signer, so a consumer can use Privy for auth/onboarding while still using every Sentinel SDK helper that takes a `wallet`. + +The adapter lives in `auth/privy-cosmos-signer.js` and is re-exported from the SDK root. + +## Two strategies + +The adapter supports two paths, selected by the `mode` field on `createPrivyCosmosSigner`. Pick the one that matches your custody requirements. + +### Mode A — `mnemonic` (seed-import) + +The consumer triggers Privy's `exportWallet()`. The user reveals their seed once. The adapter re-derives a Cosmos secp256k1 key on the standard Cosmos HD path (`m/44'/118'/0'/0/0`) and wraps it in `DirectSecp256k1HdWallet`. + +```js +import { PrivyCosmosSigner } from 'blue-js-sdk'; + +const signer = await PrivyCosmosSigner.fromMnemonic({ + mnemonic: privyExportedSeed, + prefix: 'sent', +}); + +const [account] = await signer.getAccounts(); +// account.address === 'sent1...' +``` + +Trust model: identical to a normal mnemonic wallet — the seed has left Privy's enclave. Use this when you need full broadcast capability and your UX can prompt the user to export once. + +### Mode B — `rawSign` (custody-preserving) + +The seed never leaves Privy. The consumer supplies: + +- `pubkey` — the compressed secp256k1 pubkey (33 bytes) Privy derived for this user on the Cosmos `m/44'/118'/0'/0/0` path. +- `signRawSecp256k1(digest32)` — async function that asks Privy to produce a 64-byte (`r||s`) signature over the supplied 32-byte digest using the same key. + +The adapter computes the digest of the cosmjs `SignDoc` itself, so Privy only sees a hash. + +```js +import { PrivyCosmosSigner } from 'blue-js-sdk'; + +const signer = await PrivyCosmosSigner.fromRawSign({ + pubkey: privyDerivedCompressedPubkey, + signRawSecp256k1: async (digest32) => { + const sig = await privy.signRawHash({ hash: digest32, curve: 'secp256k1' }); + return sig; // Uint8Array(64), r||s + }, + prefix: 'sent', +}); +``` + +Use this when you must keep custody inside Privy. Requirements on the callback: + +- Returns a 64-byte (`r||s`) `Uint8Array`. The adapter rejects any other shape. +- The signature MUST be over the raw 32-byte digest the adapter passed in. Do not let Privy re-hash it (no `eth_sign`-style "Ethereum Signed Message" prefixing). +- Low-S form is preferred but not required — the adapter normalizes high-S signatures to low-S before encoding the result, since cosmos-sdk validators reject high-S since v0.42. + +## Address parity + +Both modes derive the **same** `sent1...` address from the same seed. You can pre-compute the address in either direction with `deriveCosmosPubkeyFromMnemonic`: + +```js +import { deriveCosmosPubkeyFromMnemonic } from 'blue-js-sdk'; + +const { pubkey, address } = await deriveCosmosPubkeyFromMnemonic(mnemonic); +``` + +This is useful when the consumer wants to display the user's `sent1...` address in Privy onboarding before a Mode B signer is wired up. + +## What the adapter is + +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. + +## Unified factory + +```js +import { createPrivyCosmosSigner } from 'blue-js-sdk'; + +// Routes to fromMnemonic / fromRawSign by `mode`. +const signer = await createPrivyCosmosSigner({ mode: 'mnemonic', mnemonic }); +// or +const signer = await createPrivyCosmosSigner({ + mode: 'rawSign', pubkey, signRawSecp256k1, +}); +``` + +## Failure modes + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `signerAddress mismatch (got X, signer holds Y)` | Caller passed a different `signerAddress` to `signDirect` than the one the signer derived from the pubkey. | Use the address from `getAccounts()[0]`. | +| `signRawSecp256k1 must return a 64-byte (r\|\|s) Uint8Array` | Privy callback returned DER, hex string, or included a recovery byte. | Strip to fixed 64-byte `r\|\|s` before returning. | +| Chain rejects TX with `signature verification failed` | Privy hashed the input again before signing (e.g. `eth_sign` prefixing). | Use Privy's "raw hash" sign endpoint, not `signMessage`. | +| Address differs between modes | Privy derived the pubkey on a non-Cosmos path. | Use Cosmos path `m/44'/118'/0'/0/0`; coinType MUST be 118. | + +## Tests + +`test/privy-cosmos-signer.test.mjs` — 20 assertions covering: + +- Mode A address parity with `createWallet()` +- `deriveCosmosPubkeyFromMnemonic` matches Mode A +- Mode B address parity with Mode A using the same seed +- `signDirect` produces a signature that verifies against the pubkey on `sha256(makeSignBytes(signDoc))` +- High-S signatures returned by the callback are normalized to low-S +- `signerAddress` mismatch is rejected +- Unified factory routes correctly and rejects unknown modes +- Static facade delegates to the underlying functions diff --git a/index.js b/index.js index 94ef3a2..c4aa847 100644 --- a/index.js +++ b/index.js @@ -576,3 +576,14 @@ export { buildKeplrSignDoc, broadcastSignedKeplrTx, } from './auth/keplr-signdoc.js'; + +// --- Auth Utilities (Privy embedded wallets) --- + +export { + PrivyCosmosSigner, + PrivyRawSignDirectSigner, + privyCosmosSignerFromMnemonic, + privyCosmosSignerFromRawSign, + createPrivyCosmosSigner, + deriveCosmosPubkeyFromMnemonic, +} from './auth/privy-cosmos-signer.js'; diff --git a/test/privy-cosmos-signer.test.mjs b/test/privy-cosmos-signer.test.mjs new file mode 100644 index 0000000..e1ebbdb --- /dev/null +++ b/test/privy-cosmos-signer.test.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * Privy → Cosmos signer adapter tests. + * + * Covers: + * 1. Mode A (mnemonic) produces the same address as `createWallet()`. + * 2. `deriveCosmosPubkeyFromMnemonic` matches the address Mode A produces. + * 3. Mode B (rawSign) using a known privkey produces the same address. + * 4. Mode B `signDirect` returns a signature that verifies against the + * pubkey on the cosmjs `SignDoc`-derived digest. + * 5. Mode B normalizes high-S signatures to low-S. + * 6. Mode B rejects signerAddress mismatch. + * + * No network, no Privy SDK — the raw-sign callback is simulated locally + * with cosmjs's `Secp256k1.createSignature`. The interface contract is + * what the real Privy raw-sign endpoint must satisfy. + * + * Run: node test/privy-cosmos-signer.test.mjs + */ + +import { + PrivyCosmosSigner, + PrivyRawSignDirectSigner, + privyCosmosSignerFromMnemonic, + privyCosmosSignerFromRawSign, + createPrivyCosmosSigner, + deriveCosmosPubkeyFromMnemonic, + createWallet, +} from '../index.js'; +import { + Bip39, EnglishMnemonic, Slip10, Slip10Curve, Secp256k1, sha256, +} from '@cosmjs/crypto'; +import { makeCosmoshubPath } from '@cosmjs/amino'; +import { makeSignBytes } from '@cosmjs/proto-signing'; + +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('Privy → Cosmos signer adapter tests\n'); + +// ─── 1. Mode A address parity with createWallet ───────────────────────────── + +console.log('1. Mode A: address parity with createWallet()...'); +const { account: refAccount } = await createWallet(MNEMONIC); +const signerA = await privyCosmosSignerFromMnemonic({ mnemonic: MNEMONIC }); +const [accA] = await signerA.getAccounts(); +assert(accA.address === refAccount.address, + `Mode A address matches createWallet (${accA.address})`); +assert(accA.address.startsWith('sent1'), + 'Mode A address has sent1 prefix'); + +// ─── 2. deriveCosmosPubkeyFromMnemonic matches ────────────────────────────── + +console.log('\n2. deriveCosmosPubkeyFromMnemonic...'); +const derived = await deriveCosmosPubkeyFromMnemonic(MNEMONIC); +assert(derived.address === refAccount.address, + 'derive helper produces same address'); +assert(derived.pubkey instanceof Uint8Array && derived.pubkey.length === 33, + 'derive helper returns 33-byte compressed pubkey'); + +// ─── 3. Mode B address parity using the derived privkey ───────────────────── + +console.log('\n3. Mode B: rawSign address parity...'); + +// Re-derive the privkey locally (this is what Privy holds internally). +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); + +let lastDigest = null; +async function fakePrivyRawSign(digest32) { + lastDigest = digest32; + // cosmjs returns ExtendedSecp256k1Signature; r/s each 32 bytes, recovery byte present. + const sig = await Secp256k1.createSignature(digest32, privkey); + const r = sig.r(32); + const s = sig.s(32); + const out = new Uint8Array(64); + out.set(r, 0); + out.set(s, 32); + return out; +} + +const signerB = await privyCosmosSignerFromRawSign({ + pubkey: compressedPubkey, + signRawSecp256k1: fakePrivyRawSign, +}); +const [accB] = await signerB.getAccounts(); +assert(accB.address === refAccount.address, + 'Mode B address matches Mode A'); +assert(accB.algo === 'secp256k1', 'Mode B algo is secp256k1'); +assert(accB.pubkey instanceof Uint8Array && accB.pubkey.length === 33, + 'Mode B account.pubkey is 33-byte compressed'); + +// ─── 4. signDirect signature verifies ─────────────────────────────────────── + +console.log('\n4. Mode B: signDirect produces verifiable signature...'); + +// Build a synthetic SignDoc — same shape SigningStargateClient hands to a signer. +const fakeSignDoc = { + bodyBytes: new Uint8Array([1, 2, 3, 4, 5]), + authInfoBytes: new Uint8Array([6, 7, 8, 9]), + chainId: 'sentinelhub-2', + accountNumber: BigInt(42), +}; +const { signed, signature } = await signerB.signDirect(accB.address, fakeSignDoc); + +assert(signed === fakeSignDoc, 'signDirect returns the SignDoc unchanged in `signed`'); +assert(signature.pub_key.type === 'tendermint/PubKeySecp256k1', + 'signature pub_key.type is tendermint/PubKeySecp256k1'); +assert(typeof signature.signature === 'string' && signature.signature.length > 0, + 'signature.signature is non-empty base64'); + +// Recompute the digest and verify. +const expectedDigest = sha256(makeSignBytes(fakeSignDoc)); +assert(lastDigest && Buffer.from(lastDigest).equals(Buffer.from(expectedDigest)), + 'signRawSecp256k1 was called with sha256(makeSignBytes(signDoc))'); + +// Verify the returned signature against the pubkey. +const sigBytes = Buffer.from(signature.signature, 'base64'); +const { Secp256k1Signature } = await import('@cosmjs/crypto'); +const parsed = Secp256k1Signature.fromFixedLength(new Uint8Array(sigBytes)); +const ok = await Secp256k1.verifySignature(parsed, expectedDigest, compressedPubkey); +assert(ok === true, 'Returned signature verifies against the pubkey'); + +// ─── 5. Low-S normalization ───────────────────────────────────────────────── + +console.log('\n5. Mode B: high-S signature is normalized to low-S...'); + +const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); +const HALF_N = SECP256K1_N >> 1n; +function bytesToBigInt(bytes) { + let n = 0n; + for (const b of bytes) n = (n << 8n) | BigInt(b); + return n; +} +function bigIntTo32Bytes(n) { + const out = new Uint8Array(32); + for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; } + return out; +} + +// Force a high-S signature: sign normally, then if s is already low, flip it. +async function highSSign(digest32) { + const sig = await Secp256k1.createSignature(digest32, privkey); + const r = sig.r(32); + const s = sig.s(32); + let sBig = bytesToBigInt(s); + if (sBig <= HALF_N) sBig = SECP256K1_N - sBig; // make it high-S + const out = new Uint8Array(64); + out.set(r, 0); + out.set(bigIntTo32Bytes(sBig), 32); + return out; +} + +const signerHighS = new PrivyRawSignDirectSigner({ + pubkey: compressedPubkey, + signRawSecp256k1: highSSign, +}); +const { signature: sigHighS } = await signerHighS.signDirect(accB.address, fakeSignDoc); +const normalized = Buffer.from(sigHighS.signature, 'base64'); +const sNormalized = bytesToBigInt(new Uint8Array(normalized.subarray(32, 64))); +assert(sNormalized <= HALF_N, 'Adapter normalized s into the lower half of the curve'); + +// And the normalized signature still verifies. +const okNorm = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(new Uint8Array(normalized)), + expectedDigest, + compressedPubkey, +); +assert(okNorm === true, 'Normalized signature still verifies'); + +// ─── 6. signerAddress mismatch is rejected ────────────────────────────────── + +console.log('\n6. Mode B: signerAddress mismatch rejected...'); +let threw = false; +try { + await signerB.signDirect('sent1notthesigner000000000000000000000000', fakeSignDoc); +} catch (err) { + threw = err?.message?.includes('signerAddress mismatch'); +} +assert(threw, 'signDirect rejects wrong signerAddress with helpful message'); + +// ─── 7. Unified factory routes correctly ──────────────────────────────────── + +console.log('\n7. createPrivyCosmosSigner unified factory...'); +const viaFactoryA = await createPrivyCosmosSigner({ mode: 'mnemonic', mnemonic: MNEMONIC }); +const [vfaAcc] = await viaFactoryA.getAccounts(); +assert(vfaAcc.address === refAccount.address, 'factory mode=mnemonic works'); + +const viaFactoryB = await createPrivyCosmosSigner({ + mode: 'rawSign', pubkey: compressedPubkey, signRawSecp256k1: fakePrivyRawSign, +}); +const [vfbAcc] = await viaFactoryB.getAccounts(); +assert(vfbAcc.address === refAccount.address, 'factory mode=rawSign works'); + +let factoryThrew = false; +try { await createPrivyCosmosSigner({ mode: 'bogus' }); } +catch (err) { factoryThrew = err?.message?.includes('unknown mode'); } +assert(factoryThrew, 'factory rejects unknown mode'); + +// ─── 8. Static facade equivalence ─────────────────────────────────────────── + +console.log('\n8. PrivyCosmosSigner static facade...'); +const viaStatic = await PrivyCosmosSigner.fromMnemonic({ mnemonic: MNEMONIC }); +const [staticAcc] = await viaStatic.getAccounts(); +assert(staticAcc.address === refAccount.address, + 'PrivyCosmosSigner.fromMnemonic delegates correctly'); + +const viaStaticDerive = await PrivyCosmosSigner.derivePubkeyFromMnemonic(MNEMONIC); +assert(viaStaticDerive.address === refAccount.address, + 'PrivyCosmosSigner.derivePubkeyFromMnemonic delegates correctly'); + +// ─── 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 Privy adapter tests passed.');