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
18 changes: 18 additions & 0 deletions docs/PRIVY-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
145 changes: 145 additions & 0 deletions test/privy-live-chain.test.mjs
Original file line number Diff line number Diff line change
@@ -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.');
179 changes: 179 additions & 0 deletions test/privy-real-server.test.mjs
Original file line number Diff line number Diff line change
@@ -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.');
Loading