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
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@c7450ba673e133f5ee30098b3b54f444d3a2ca2d # v1
with:
version: 1.7.0
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- working-directory: packages/protokoll
run: forge build
- run: pnpm -r test
127 changes: 127 additions & 0 deletions packages/protokoll/test/differential/fuzz.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, beforeAll, afterAll, it, expect } from 'vitest';
import { generateOracleProof, encodePointHex } from '../../src/oracle/proof.js';
import { scalarMul, G1_GENERATOR, CURVE_ORDER } from '../../src/math/curve.js';
import { startHarness, stopHarness, verify, type Harness } from './harness.js';
import { proofToArgs, flipBitInHex, makeRoundId, bytesToHex } from './proofArgs.js';

const MUTATIONS_PER_CATEGORY = Number.parseInt(process.env.HARNESS_MUTATIONS ?? '50', 10);

describe('differential harness - mutation matrix', () => {
let h: Harness;
let baselineArgs: ReturnType<typeof proofToArgs>;

beforeAll(async () => {
h = await startHarness();
const k = 0xdeadbeefn;
const roundId = makeRoundId('fuzz-baseline');
const proof = await generateOracleProof(k, roundId);
baselineArgs = proofToArgs(proof, roundId);
const ok = await verify(h, baselineArgs);
if (!ok) throw new Error('baseline proof failed to verify - cannot fuzz');
}, 120_000);

afterAll(async () => { if (h) await stopHarness(h); });

it(`gamma bit flip x ${MUTATIONS_PER_CATEGORY}`, async () => {
const indices = pickBitIndices(128 * 8, MUTATIONS_PER_CATEGORY);
for (const bit of indices) {
const tampered = flipBitInHex(baselineArgs.gamma, bit);
const out = await verify(h, { ...baselineArgs, gamma: tampered });
if (out) throw new Error(`gamma bit flip at index ${bit} verified - leak`);
expect(out).toBe(false);
}
}, 120_000);

it(`c bit flip x ${MUTATIONS_PER_CATEGORY}`, async () => {
const cBytes = scalarToBytes(baselineArgs.c);
const indices = pickBitIndices(cBytes.length * 8, MUTATIONS_PER_CATEGORY);
for (const bit of indices) {
const mutated = flipBit(cBytes, bit);
const c = bytesToBigint(mutated) % CURVE_ORDER;
if (c === baselineArgs.c) continue;
const out = await verify(h, { ...baselineArgs, c });
if (out) throw new Error(`c bit flip at index ${bit} verified - leak`);
expect(out).toBe(false);
}
}, 120_000);

it(`s bit flip x ${MUTATIONS_PER_CATEGORY}`, async () => {
const sBytes = scalarToBytes(baselineArgs.s);
const indices = pickBitIndices(sBytes.length * 8, MUTATIONS_PER_CATEGORY);
for (const bit of indices) {
const mutated = flipBit(sBytes, bit);
const s = bytesToBigint(mutated) % CURVE_ORDER;
if (s === baselineArgs.s) continue;
const out = await verify(h, { ...baselineArgs, s });
if (out) throw new Error(`s bit flip at index ${bit} verified - leak`);
expect(out).toBe(false);
}
}, 120_000);

it(`mutated roundId x ${MUTATIONS_PER_CATEGORY}`, async () => {
for (let i = 0; i < MUTATIONS_PER_CATEGORY; i++) {
const altered = randomBytes32();
if (altered === baselineArgs.roundId) continue;
const out = await verify(h, { ...baselineArgs, roundId: altered });
if (out) throw new Error(`mutated roundId ${altered} verified - leak`);
expect(out).toBe(false);
}
}, 120_000);

it(`mutated publicKey x ${MUTATIONS_PER_CATEGORY}`, async () => {
for (let i = 0; i < MUTATIONS_PER_CATEGORY; i++) {
const r = randomScalar();
const fakePoint = scalarMul(r, G1_GENERATOR);
if (fakePoint === 'infinity') continue;
const fakeKeyHex = encodePointHex(fakePoint);
if (fakeKeyHex === baselineArgs.publicKey) continue;
const out = await verify(h, { ...baselineArgs, publicKey: fakeKeyHex });
if (out) throw new Error(`mutated publicKey ${fakeKeyHex} verified - leak`);
expect(out).toBe(false);
}
}, 240_000);
});

function pickBitIndices(total: number, count: number): number[] {
const out = new Set<number>();
while (out.size < count && out.size < total) {
out.add(Math.floor(Math.random() * total));
}
return [...out];
}

function scalarToBytes(n: bigint): Uint8Array {
const buf = new Uint8Array(32);
let v = n;
for (let i = 31; i >= 0; i--) { buf[i] = Number(v & 0xffn); v >>= 8n; }
return buf;
}

function bytesToBigint(b: Uint8Array): bigint {
let n = 0n;
for (const x of b) n = (n << 8n) | BigInt(x);
return n;
}

function flipBit(b: Uint8Array, bitIndex: number): Uint8Array {
const out = new Uint8Array(b);
const byteIdx = Math.floor(bitIndex / 8);
const bit = bitIndex % 8;
out[byteIdx] ^= (1 << (7 - bit));
return out;
}

function randomBytes32(): `0x${string}` {
const buf = new Uint8Array(32);
for (let i = 0; i < 32; i++) buf[i] = Math.floor(Math.random() * 256);
return bytesToHex(buf);
}

function randomScalar(): bigint {
const buf = new Uint8Array(32);
for (let i = 0; i < 32; i++) buf[i] = Math.floor(Math.random() * 256);
let n = 0n;
for (const x of buf) n = (n << 8n) | BigInt(x);
n = n % CURVE_ORDER;
return n === 0n ? 1n : n;
}
128 changes: 128 additions & 0 deletions packages/protokoll/test/differential/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { spawn, type ChildProcess, execSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createPublicClient, createWalletClient, http, type Address, type Hex, type PublicClient, type WalletClient } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { foundry } from 'viem/chains';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = join(__dirname, '..', '..');
const ARTIFACT_PATH = join(PACKAGE_ROOT, 'out', 'MonadVRFVerifier.sol', 'MonadVRFVerifier.json');

const DEV_PRIVATE_KEY: Hex = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';

export type Harness = {
anvil: ChildProcess;
port: number;
publicClient: PublicClient;
walletClient: WalletClient;
verifier: Address;
rpcUrl: string;
};

type Artifact = {
abi: unknown[];
bytecode: { object: Hex } | Hex;
};

function pickPort(): number {
return 30000 + Math.floor(Math.random() * 20000);
}

async function waitForRpc(rpcUrl: string, timeoutMs = 15_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(rpcUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'web3_clientVersion', params: [] }),
});
if (res.ok) return;
} catch {}
await new Promise(r => setTimeout(r, 100));
}
throw new Error(`anvil did not respond on ${rpcUrl} within ${timeoutMs}ms`);
}

function loadArtifact(): Artifact {
if (!existsSync(ARTIFACT_PATH)) {
execSync('forge build', { cwd: PACKAGE_ROOT, stdio: 'inherit' });
}
const json = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
return { abi: json.abi, bytecode: json.bytecode };
}

export async function startHarness(): Promise<Harness> {
const port = pickPort();
const rpcUrl = `http://127.0.0.1:${port}`;

const anvil = spawn(
'anvil',
['--port', String(port), '--hardfork', 'prague', '--silent'],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
anvil.stderr?.on('data', (chunk) => {
if (process.env.HARNESS_DEBUG) process.stderr.write(`[anvil] ${chunk}`);
});

await waitForRpc(rpcUrl);

const account = privateKeyToAccount(DEV_PRIVATE_KEY);
const transport = http(rpcUrl);
const publicClient = createPublicClient({ chain: foundry, transport });
const walletClient = createWalletClient({ account, chain: foundry, transport });

const artifact = loadArtifact();
const bytecode: Hex = typeof artifact.bytecode === 'string' ? artifact.bytecode : artifact.bytecode.object;

const hash = await walletClient.deployContract({
abi: artifact.abi as never,
account,
chain: foundry,
bytecode,
args: [],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (!receipt.contractAddress) throw new Error('verifier deployment failed: no contractAddress');

return { anvil, port, publicClient, walletClient, verifier: receipt.contractAddress, rpcUrl };
}

export async function stopHarness(h: Harness): Promise<void> {
return new Promise((resolve) => {
h.anvil.once('exit', () => resolve());
h.anvil.kill('SIGTERM');
setTimeout(() => { try { h.anvil.kill('SIGKILL'); } catch {} }, 2000);
});
}

export const VERIFIER_ABI = [
{
type: 'function',
name: 'verifyProof',
stateMutability: 'view',
inputs: [
{ name: 'publicKey', type: 'bytes' },
{ name: 'roundId', type: 'bytes32' },
{ name: 'gamma', type: 'bytes' },
{ name: 'c', type: 'uint256' },
{ name: 's', type: 'uint256' },
],
outputs: [{ type: 'bool' }],
},
] as const;

export async function verify(
h: Harness,
args: { publicKey: Hex; roundId: Hex; gamma: Hex; c: bigint; s: bigint },
): Promise<boolean> {
const result = await h.publicClient.readContract({
address: h.verifier,
abi: VERIFIER_ABI,
functionName: 'verifyProof',
args: [args.publicKey, args.roundId, args.gamma, args.c, args.s],
});
return result as boolean;
}
38 changes: 38 additions & 0 deletions packages/protokoll/test/differential/proofArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Hex } from 'viem';
import { encodePoint, encodePointHex, type OracleProof } from '../../src/oracle/proof.js';

export function bytesToHex(bytes: Uint8Array): Hex {
return `0x${Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')}` as Hex;
}

export type VerifyArgs = { publicKey: Hex; roundId: Hex; gamma: Hex; c: bigint; s: bigint };

export function proofToArgs(proof: OracleProof, roundId: Uint8Array): VerifyArgs {
return {
publicKey: encodePointHex(proof.publicKey),
roundId: bytesToHex(roundId) as Hex,
gamma: encodePointHex(proof.gamma),
c: proof.c,
s: proof.s,
};
}

export function flipBitInHex(hex: Hex, bitIndex: number): Hex {
const raw = hex.startsWith('0x') ? hex.slice(2) : hex;
const bytes = new Uint8Array(raw.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(raw.slice(i * 2, i * 2 + 2), 16);
const byteIdx = Math.floor(bitIndex / 8);
const bit = bitIndex % 8;
if (byteIdx >= bytes.length) throw new Error(`bit index ${bitIndex} out of range for ${bytes.length}-byte buffer`);
bytes[byteIdx] ^= (1 << (7 - bit));
return bytesToHex(bytes);
}

export function makeRoundId(label: string): Uint8Array {
const b = new Uint8Array(32);
const enc = new TextEncoder().encode(label);
b.set(enc.slice(0, 32));
return b;
}

export { encodePoint, encodePointHex };
29 changes: 29 additions & 0 deletions packages/protokoll/test/differential/smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, beforeAll, afterAll, it, expect } from 'vitest';
import { generateOracleProof } from '../../src/oracle/proof.js';
import { startHarness, stopHarness, verify, type Harness } from './harness.js';
import { proofToArgs, flipBitInHex, makeRoundId } from './proofArgs.js';

describe('differential harness - smoke', () => {
let h: Harness;

beforeAll(async () => { h = await startHarness(); }, 60_000);
afterAll(async () => { if (h) await stopHarness(h); });

it('valid proof verifies end-to-end', async () => {
const k = 42n;
const roundId = makeRoundId('round-1');
const proof = await generateOracleProof(k, roundId);
const args = proofToArgs(proof, roundId);
expect(await verify(h, args)).toBe(true);
});

it('one-bit flip in gamma causes verification to fail', async () => {
const k = 42n;
const roundId = makeRoundId('round-1');
const proof = await generateOracleProof(k, roundId);
const args = proofToArgs(proof, roundId);
const tamperedGamma = flipBitInHex(args.gamma, 1023);
const out = await verify(h, { ...args, gamma: tamperedGamma });
expect(out).toBe(false);
});
});
Loading