From 5db838e5700724eb2729de3293462ad5cb54897a Mon Sep 17 00:00:00 2001 From: Human and Agent dVPN <271368948+Sentinel-Autonomybuilder@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:41:10 -0700 Subject: [PATCH] test(privy): add live-chain + real-server end-to-end tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two opt-in tests, gated by env vars, not run in CI: test/privy-live-chain.test.mjs (MNEMONIC=...): - Constructs SentinelClient with both Mode A (mnemonic) and Mode B (raw-sign) signers and queries getBalance() against mainnet RPC. - Broadcasts a real self-MsgSend(1 udvpn) signed by Mode B's raw-sign callback. Proves cosmos-sdk validators accept the signature shape the adapter produces (low-S, r||s, makeSignBytes-derived digest). - Verified mainnet TX 4A6BA72AA4AF6A05BE10E8037AF31A5B1D6378A520FB4C0F05BF9CDD6FF50C7E at height 28097905 — 7/7 assertions pass. test/privy-real-server.test.mjs (PRIVY_APP_ID, PRIVY_APP_SECRET): - Hits Privy's real REST API: POST /v1/wallets {chain_type: cosmos} creates a server-managed wallet, then POST /v1/wallets/{id}/raw_sign backs the adapter's signRawSecp256k1 callback. - Verifies the cosmjs SignDoc signature the adapter assembles from Privy's response bytes verifies against the Privy-derived 33-byte compressed pubkey using @cosmjs/crypto. 13/13 assertions pass. Also: added npm scripts test:privy / test:privy:live / test:privy:server and a "Run the live suites" section in docs/PRIVY-INTEGRATION.md. Files NOT shipped in npm package (test/ not in package.json files allowlist). --- docs/PRIVY-INTEGRATION.md | 18 ++++ package.json | 3 + test/privy-live-chain.test.mjs | 145 ++++++++++++++++++++++++++ test/privy-real-server.test.mjs | 179 ++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 test/privy-live-chain.test.mjs create mode 100644 test/privy-real-server.test.mjs diff --git a/docs/PRIVY-INTEGRATION.md b/docs/PRIVY-INTEGRATION.md index ba54bdc..1774123 100644 --- a/docs/PRIVY-INTEGRATION.md +++ b/docs/PRIVY-INTEGRATION.md @@ -150,6 +150,24 @@ const signer = await createPrivyCosmosSigner({ - Unified factory routes correctly and rejects unknown modes - Static facade delegates to the underlying functions +### Run the offline suite (CI-safe) + +```sh +npm run test:privy # 32 assertions, no network +``` + +### Run the live suites (require credentials, NOT in CI) + +```sh +# Mainnet broadcast — proves Sentinel chain accepts adapter signatures. +# Sends a 1 udvpn self-MsgSend; needs ~20000 udvpn for fee. +MNEMONIC="..." npm run test:privy:live + +# Real Privy API — creates a server-managed Cosmos wallet on Privy and proves +# the bytes Privy's /raw_sign returns verify against the Privy-derived pubkey. +PRIVY_APP_ID="..." PRIVY_APP_SECRET="..." npm run test:privy:server +``` + `test/privy-client-integration.test.mjs` — 12 assertions covering: - `SentinelClient({ signer })` — `getWallet()` returns the supplied signer + first account, no mnemonic required diff --git a/package.json b/package.json index a7f1e65..b24f380 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,9 @@ "proto:generate": "npx protoc --ts_proto_out=generated --ts_proto_opt=esModuleInterop=true --ts_proto_opt=env=node --ts_proto_opt=outputTypeRegistry=true --proto_path=proto proto/sentinel/types/v1/*.proto proto/sentinel/node/v3/*.proto proto/sentinel/session/v3/*.proto proto/sentinel/plan/v3/*.proto proto/sentinel/subscription/v3/*.proto proto/sentinel/lease/v1/*.proto proto/sentinel/provider/v2/*.proto", "test": "node test/smoke.js", "test:exports": "node -e \"import('./index.js').then(m => console.log(Object.keys(m).length + ' exports OK'))\"", + "test:privy": "node test/privy-cosmos-signer.test.mjs && node test/privy-client-integration.test.mjs", + "test:privy:live": "node test/privy-live-chain.test.mjs", + "test:privy:server": "node test/privy-real-server.test.mjs", "postinstall": "node bin/setup.js || true" }, "keywords": [ diff --git a/test/privy-live-chain.test.mjs b/test/privy-live-chain.test.mjs new file mode 100644 index 0000000..06d2781 --- /dev/null +++ b/test/privy-live-chain.test.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * Live-chain verification of the Privy adapter — mainnet, NOT in CI. + * + * Reads MNEMONIC from env (load it from a project-private .env before running). + * + * What this proves end-to-end against Sentinel mainnet: + * 1. SentinelClient({ signer: PrivyCosmosSigner.fromMnemonic(...) }) — getBalance() works. + * 2. SentinelClient({ signer: PrivyRawSignDirectSigner(...) }) where the raw-sign + * callback is exactly the shape Privy's "sign raw secp256k1 hash" endpoint + * delivers — getBalance() works AND a self-MsgSend of 1 udvpn broadcasts and + * lands in a block. This is the actual contract: chain validators verify the + * signature the adapter produces. + * 3. Mode A and Mode B derive the same sent1... address from the same seed. + * + * Usage: + * MNEMONIC="..." node test/privy-live-chain.test.mjs + */ + +import { + SentinelClient, + PrivyCosmosSigner, + PrivyRawSignDirectSigner, + privyCosmosSignerFromMnemonic, +} from '../index.js'; +import { + Bip39, EnglishMnemonic, Slip10, Slip10Curve, Secp256k1, +} from '@cosmjs/crypto'; +import { makeCosmoshubPath } from '@cosmjs/amino'; + +const MNEMONIC = process.env.MNEMONIC; +if (!MNEMONIC) { + console.error('MNEMONIC env var required'); + process.exit(2); +} +const RPC = process.env.RPC_URL || 'https://rpc.sentinel.co:443'; + +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}`); } +} + +console.log('Privy adapter — LIVE CHAIN integration test\n'); +console.log(`RPC: ${RPC}\n`); + +// ─── Set up a "fake Privy" raw-sign callback backed by the local privkey ──── +// This is what Privy's signRawHash({ hash, curve: 'secp256k1' }) delivers shape- +// for-shape: 64 bytes (r||s), no recovery byte, signed over the raw 32-byte +// digest the SDK passes in (no eth_sign-style prefixing). + +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 rawSignCallCount = 0; +async function privyLikeRawSign(digest32) { + rawSignCallCount++; + if (!(digest32 instanceof Uint8Array) || digest32.length !== 32) { + throw new Error(`Privy callback received bad digest: ${digest32?.length} bytes`); + } + 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; +} + +// ─── 1. Mode A — SentinelClient + Privy mnemonic signer ───────────────────── + +console.log('1. Mode A: SentinelClient({ signer: PrivyCosmosSigner.fromMnemonic })...'); +const modeASigner = await PrivyCosmosSigner.fromMnemonic({ mnemonic: MNEMONIC }); +const [modeAAcc] = await modeASigner.getAccounts(); +console.log(` address: ${modeAAcc.address}`); + +const clientA = new SentinelClient({ signer: modeASigner, rpcUrl: RPC, mnemonic: MNEMONIC }); +const balA = await clientA.getBalance(); +console.log(` balance: ${balA.dvpn} P2P (${balA.udvpn} udvpn)`); +assert(typeof balA.udvpn === 'bigint' || typeof balA.udvpn === 'number', + 'Mode A getBalance() returned a numeric balance'); +assert(modeAAcc.address.startsWith('sent1'), 'Mode A address has sent1 prefix'); +clientA.destroy(); + +// ─── 2. Mode B — SentinelClient + Privy raw-sign signer ───────────────────── + +console.log('\n2. Mode B: SentinelClient({ signer: PrivyRawSignDirectSigner })...'); +const modeBSigner = new PrivyRawSignDirectSigner({ + pubkey: compressedPubkey, + signRawSecp256k1: privyLikeRawSign, +}); +const [modeBAcc] = await modeBSigner.getAccounts(); +console.log(` address: ${modeBAcc.address}`); +assert(modeBAcc.address === modeAAcc.address, + 'Mode B address matches Mode A (same seed, same path, same prefix)'); + +const clientB = new SentinelClient({ signer: modeBSigner, rpcUrl: RPC }); +const balB = await clientB.getBalance(); +console.log(` balance: ${balB.dvpn} P2P`); +assert(balB.udvpn === balA.udvpn, + 'Mode B getBalance() matches Mode A balance (same address)'); + +// ─── 3. Mode B — broadcast a real TX (self-MsgSend, 1 udvpn) ──────────────── + +console.log('\n3. Mode B: broadcasting real TX (self-MsgSend of 1 udvpn)...'); +console.log(' This proves chain validators accept signatures the adapter produces.'); + +if (balA.udvpn === 0n || balA.udvpn === 0) { + console.log(' SKIP: wallet is empty — fund it with at least gas + 1 udvpn to run this step.'); +} else { + const sigClient = await clientB.getClient(); + const before = rawSignCallCount; + const fee = { amount: [{ denom: 'udvpn', amount: '20000' }], gas: '200000' }; + const result = await sigClient.sendTokens( + modeBAcc.address, + modeBAcc.address, // self-send + [{ denom: 'udvpn', amount: '1' }], + fee, + 'privy-adapter live test', + ); + console.log(` tx hash: ${result.transactionHash}`); + console.log(` height : ${result.height}`); + console.log(` code : ${result.code}`); + console.log(` raw-sign calls: ${rawSignCallCount - before}`); + + assert(result.code === 0, 'broadcast succeeded with code 0'); + assert(typeof result.transactionHash === 'string' && result.transactionHash.length === 64, + 'broadcast returned a 64-char tx hash'); + assert(rawSignCallCount > before, + 'Privy-style raw-sign callback was actually invoked during broadcast'); +} + +clientB.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('Live-chain Privy integration verified.'); diff --git a/test/privy-real-server.test.mjs b/test/privy-real-server.test.mjs new file mode 100644 index 0000000..8dfe594 --- /dev/null +++ b/test/privy-real-server.test.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * Real-Privy-server end-to-end test — NOT in CI, requires app credentials. + * + * Reads PRIVY_APP_ID and PRIVY_APP_SECRET from env. Creates a fresh server- + * managed Cosmos secp256k1 wallet on Privy, then drives PrivyRawSignDirectSigner + * with a callback that hits the real /v1/wallets/{id}/raw_sign endpoint, and + * verifies the resulting cosmjs SignDirect signature against the Privy-derived + * pubkey using @cosmjs/crypto's verifier. + * + * What this proves end-to-end: + * 1. Privy's create-wallet returns a 33-byte compressed secp256k1 pubkey + * that pubkeyToBech32Address() can hash into a valid sent1... address. + * 2. The bytes Privy's raw_sign returns are exactly the r||s shape the + * adapter expects — no recovery byte, no DER, no eth_sign prefixing. + * 3. The signature the adapter assembles for a fake SignDoc verifies + * against the Privy-derived pubkey on sha256(makeSignBytes(signDoc)). + * + * No chain broadcast — the test wallet has no funds. + * + * Usage: + * PRIVY_APP_ID=... PRIVY_APP_SECRET=... node test/privy-real-server.test.mjs + */ + +import { + PrivyRawSignDirectSigner, +} from '../index.js'; +import { Secp256k1, sha256 } from '@cosmjs/crypto'; +import { Secp256k1Signature } from '@cosmjs/crypto'; +import { fromHex } from '@cosmjs/encoding'; +import { makeSignBytes } from '@cosmjs/proto-signing'; + +const APP_ID = process.env.PRIVY_APP_ID; +const APP_SECRET = process.env.PRIVY_APP_SECRET; +if (!APP_ID || !APP_SECRET) { + console.error('PRIVY_APP_ID and PRIVY_APP_SECRET env vars required'); + process.exit(2); +} + +const BASIC = Buffer.from(`${APP_ID}:${APP_SECRET}`).toString('base64'); +const HEADERS = { + 'Authorization': `Basic ${BASIC}`, + 'privy-app-id': APP_ID, + 'Content-Type': 'application/json', +}; + +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}`); } +} + +console.log('Privy → Sentinel — REAL SERVER end-to-end\n'); + +// ─── 1. Create a fresh server-managed Cosmos wallet on Privy ──────────────── + +console.log('1. Creating server-managed Cosmos wallet via Privy API...'); +const createRes = await fetch('https://api.privy.io/v1/wallets', { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ chain_type: 'cosmos' }), +}); +const createJson = await createRes.json(); +console.log(` HTTP ${createRes.status}`); +if (!createRes.ok) { + console.error(' error response:', JSON.stringify(createJson)); + process.exit(1); +} +console.log(` wallet id : ${createJson.id}`); +console.log(` address : ${createJson.address}`); +console.log(` public_key : ${createJson.public_key}`); +console.log(` chain_type : ${createJson.chain_type}`); + +assert(createJson.chain_type === 'cosmos', 'wallet chain_type === cosmos'); +assert(typeof createJson.id === 'string' && createJson.id.length > 0, + 'wallet id is a non-empty string'); +assert(typeof createJson.public_key === 'string' && createJson.public_key.length > 0, + 'public_key is present'); + +// Privy returns the public_key as a hex string. Verify it parses to 33 bytes +// (compressed secp256k1). +const pubHex = createJson.public_key.startsWith('0x') + ? createJson.public_key.slice(2) + : createJson.public_key; +const pubkey = fromHex(pubHex); +console.log(` pubkey bytes: ${pubkey.length}`); +assert(pubkey.length === 33, + `Privy public_key parses to 33 bytes (got ${pubkey.length})`); +assert(pubkey[0] === 0x02 || pubkey[0] === 0x03, + `Privy public_key has compressed prefix 0x02/0x03 (got 0x${pubkey[0].toString(16)})`); + +// ─── 2. Wire the Privy raw_sign endpoint into the adapter ─────────────────── + +console.log('\n2. Wiring Privy /raw_sign callback into PrivyRawSignDirectSigner...'); + +let rawSignCallCount = 0; +async function privyRawSign(digest32) { + rawSignCallCount++; + if (!(digest32 instanceof Uint8Array) || digest32.length !== 32) { + throw new Error(`bad digest: ${digest32?.length} bytes`); + } + const hashHex = '0x' + Buffer.from(digest32).toString('hex'); + const res = await fetch(`https://api.privy.io/v1/wallets/${createJson.id}/raw_sign`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ params: { hash: hashHex } }), + }); + const j = await res.json(); + if (!res.ok) { + throw new Error(`Privy raw_sign HTTP ${res.status}: ${JSON.stringify(j)}`); + } + const sigHex = j?.data?.signature; + if (typeof sigHex !== 'string') { + throw new Error(`Privy raw_sign returned no signature: ${JSON.stringify(j)}`); + } + const stripped = sigHex.startsWith('0x') ? sigHex.slice(2) : sigHex; + let bytes = fromHex(stripped); + // Privy may return r||s||recovery (65 bytes); the adapter contract is r||s (64). + if (bytes.length === 65) bytes = bytes.slice(0, 64); + if (bytes.length !== 64) { + throw new Error(`Privy raw_sign returned ${bytes.length} bytes, expected 64`); + } + return bytes; +} + +const signer = new PrivyRawSignDirectSigner({ + pubkey, + signRawSecp256k1: privyRawSign, + prefix: 'sent', // Privy returns address with default cosmos1 prefix; adapter + // re-derives the address from pubkey using the requested prefix. +}); + +const [acc] = await signer.getAccounts(); +console.log(` adapter address (sent prefix): ${acc.address}`); +assert(acc.address.startsWith('sent1'), 'adapter re-derives sent1... address from Privy pubkey'); +assert(acc.algo === 'secp256k1', 'adapter reports algo=secp256k1'); +assert(acc.pubkey.length === 33, 'adapter exposes the 33-byte pubkey unchanged'); + +// ─── 3. Drive signDirect with a synthetic SignDoc and verify the signature ── + +console.log('\n3. signDirect — Privy actually signs, signature verifies on cosmjs side...'); +const fakeSignDoc = { + bodyBytes: new Uint8Array([1, 2, 3, 4, 5]), + authInfoBytes: new Uint8Array([6, 7, 8, 9]), + chainId: 'sentinelhub-2', + accountNumber: BigInt(0), +}; + +const beforeCalls = rawSignCallCount; +const { signed, signature } = await signer.signDirect(acc.address, fakeSignDoc); +const afterCalls = rawSignCallCount; +console.log(` raw_sign calls: ${afterCalls - beforeCalls}`); + +assert(afterCalls === beforeCalls + 1, + 'signDirect made exactly one HTTP call to Privy /raw_sign'); +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'); + +const expectedDigest = sha256(makeSignBytes(fakeSignDoc)); +const sigBytes = Buffer.from(signature.signature, 'base64'); +const parsed = Secp256k1Signature.fromFixedLength(new Uint8Array(sigBytes)); +const ok = await Secp256k1.verifySignature(parsed, expectedDigest, pubkey); +assert(ok === true, + 'Privy-produced signature VERIFIES on the Sentinel-side digest with @cosmjs/crypto'); + +// ─── 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('Privy real server → Sentinel adapter end-to-end verified.');