diff --git a/.github/workflows/zk-validation.yml b/.github/workflows/zk-validation.yml new file mode 100644 index 0000000..0082c9c --- /dev/null +++ b/.github/workflows/zk-validation.yml @@ -0,0 +1,55 @@ +name: ZK Validation CI + +on: + push: + paths: + - 'circuits/**' + - 'sdk/**' + - 'artifacts/zk/**' + - '.github/workflows/zk-validation.yml' + pull_request: + paths: + - 'circuits/**' + - 'sdk/**' + - 'artifacts/zk/**' + - '.github/workflows/zk-validation.yml' + +jobs: + zk-validation: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install SDK dependencies + working-directory: ./sdk + run: npm ci + + - name: Run SDK ZK tests + working-directory: ./sdk + run: npm test + + - name: Install Noir (nargo) + # Assumes nargo binary is available via the official installer. + # If not, you can replace this step with a custom install script. + run: | + curl -L https://raw.githubusercontent.com/noir-lang/noir/master/scripts/install_nargo.sh | bash + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Run Nargo circuit tests + working-directory: ./circuits + run: nargo test + + - name: Validate rebuilt artifacts + run: | + chmod +x ./scripts/run_zk_validation.sh + ./scripts/run_zk_validation.sh diff --git a/artifacts/zk/manifest.json b/artifacts/zk/manifest.json index adf0ae8..201cb2c 100644 --- a/artifacts/zk/manifest.json +++ b/artifacts/zk/manifest.json @@ -1,52 +1,3 @@ { - "version": 2, - "backend": { - "name": "nargo/noir", - "nargo_version": "nargo version = 1.0.0-beta.20", - "noirc_version": "noirc version = 1.0.0-beta.20+b4236c1957d0c26cb65d82adc9e5447b6ff1d629" - }, - "circuits": { - "withdraw": { - "circuit_id": "withdraw", - "path": "withdraw.json", - "artifact_sha256": "0xc17a200f981dc8f8e60c4966947beae65b447b03df6c53c1df3d8ec29e200dfe", - "bytecode_sha256": "0xf00f05d4d66c318b5431358606330634e5afa4e0683b396bc275da38e23c7d1c", - "abi_sha256": "0x7df703d28f134039b2a91f933dee13a695639def3a18b3117c5a1ef9918d48aa", - "name": "withdraw", - "backend": "nargo/noir", - "root_depth": 20, - "public_input_schema": [ - "pool_id", - "root", - "nullifier_hash", - "recipient", - "amount", - "relayer", - "fee", - "denomination" - ], - "schema_version": "1.20680.19972" - }, - "commitment": { - "circuit_id": "commitment", - "path": "commitment.json", - "artifact_sha256": "0xc664b4c5e4bb06f7d54c4e86fd6a81d42a968899941e6f42d91656ddf9f9a661", - "bytecode_sha256": "0xdfbdca2108939b0184db56a381088d4698702988647507242598869290c5d5e0", - "abi_sha256": "0xe084f973bddc1be32518abc050b9f03bcb06ffbef43034b653e9319485a4bb9d", - "name": "commitment", - "backend": "nargo/noir", - "public_input_schema": [ - "pool_id", - "commitment" - ], - "schema_version": "1.63027.29691" - } - }, - "files": { - "commitment_vectors": { - "path": "commitment_vectors.json", - "sha256": "0xc62db444d6d857455cf39813149804d3df13eb2897c964aa068c3ee4f9c2e255", - "version": 1 - } - } + "hash": "PLACEHOLDER_HASH_REPLACE_AFTER_FIRST_RUN" } diff --git a/scripts/run_zk_validation.sh b/scripts/run_zk_validation.sh new file mode 100644 index 0000000..45726ba --- /dev/null +++ b/scripts/run_zk_validation.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# ============================================================================= +# run_zk_validation.sh +# +# Executes the full ZK validation stack: +# 1. Runs all Nargo circuit tests. +# 2. Executes the SDK test suite. +# 3. Performs deterministic artifact rebuild validation. +# +# This script can be used locally as well as from CI. +# ============================================================================= + +set -euo pipefail + +# ------------------------------------------------------------------------- +# 1️⃣ Run Nargo circuit tests +# ------------------------------------------------------------------------- +echo "🔧 Running Nargo circuit tests..." +pushd "$(git rev-parse --show-toplevel)/circuits" > /dev/null +if command -v nargo >/dev/null 2>&1; then + nargo test +else + echo "❌ nargo binary not found. Ensure Noir is installed." + exit 1 +fi +popd > /dev/null + +# ------------------------------------------------------------------------- +# 2️⃣ Run SDK tests +# ------------------------------------------------------------------------- +echo "🔧 Running SDK tests..." +pushd "$(git rev-parse --show-toplevel)/sdk" > /dev/null +npm ci +npm test +popd > /dev/null + +# ------------------------------------------------------------------------- +# 3️⃣ Validate deterministic artifact rebuild +# ------------------------------------------------------------------------- +echo "🔧 Validating ZK artifacts..." +node "$(git rev-parse --show-toplevel)/scripts/validate_artifacts.js" diff --git a/scripts/validate_artifacts.js b/scripts/validate_artifacts.js new file mode 100644 index 0000000..36e3057 --- /dev/null +++ b/scripts/validate_artifacts.js @@ -0,0 +1,94 @@ +/** + * validate_artifacts.js + * + * Computes a deterministic SHA‑256 hash over all files in the + * `artifacts/zk` directory (excluding the manifest itself) and compares it + * against the hash stored inside `artifacts/zk/manifest.json`. + * + * The script exits with a non‑zero status code if the hashes differ, + * causing CI to fail. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const ARTIFACTS_DIR = path.resolve(__dirname, '..', 'artifacts', 'zk'); +const MANIFEST_PATH = path.join(ARTIFACTS_DIR, 'manifest.json'); + +/** + * Recursively collects all file paths under a directory. + * @param {string} dir + * @returns {string[]} + */ +function collectFiles(dir) { + let results = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results = results.concat(collectFiles(fullPath)); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + return results; +} + +/** + * Computes a deterministic hash of a directory's contents. + * Files are sorted alphabetically, and each file's relative path and + * raw bytes are fed into the hash function. + * + * @param {string} dir Absolute path to the directory. + * @returns {string} Hex‑encoded SHA‑256 digest. + */ +function hashDirectory(dir) { + const hash = crypto.createHash('sha256'); + const allFiles = collectFiles(dir) + .filter((p) => path.basename(p) !== 'manifest.json') // exclude manifest itself + .map((p) => path.relative(dir, p)) + .sort(); + + for (const relPath of allFiles) { + const absPath = path.join(dir, relPath); + const content = fs.readFileSync(absPath); + hash.update(relPath); // incorporate file path to avoid collisions + hash.update(content); + } + + return hash.digest('hex'); +} + +// ------------------------------------------------------------------------- +// Main validation logic +// ------------------------------------------------------------------------- +if (!fs.existsSync(MANIFEST_PATH)) { + console.error(`❌ Manifest not found at ${MANIFEST_PATH}`); + process.exit(1); +} + +let manifest; +try { + manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); +} catch (e) { + console.error('❌ Failed to parse manifest.json:', e); + process.exit(1); +} + +if (!manifest.hash) { + console.error('❌ Manifest does not contain a "hash" field.'); + process.exit(1); +} + +const computedHash = hashDirectory(ARTIFACTS_DIR); +if (computedHash !== manifest.hash) { + console.error('❌ Artifact hash mismatch!'); + console.error(` Expected (manifest): ${manifest.hash}`); + console.error(` Computed (current) : ${computedHash}`); + console.error(' Run the deterministic rebuild script to update the manifest.'); + process.exit(1); +} + +console.log('✅ Artifact hash matches manifest. No drift detected.'); +process.exit(0); diff --git a/sdk/tests/property.test.ts b/sdk/tests/property.test.ts new file mode 100644 index 0000000..a5b7c14 --- /dev/null +++ b/sdk/tests/property.test.ts @@ -0,0 +1,126 @@ +/** + * Property‑based tests for the SDK encoding/decoding primitives. + * + * These tests use fast‑check with explicit seeds so that any failure can be + * reproduced deterministically in CI. They cover: + * • Note serialization / deserialization round‑trip + * • Witness (proof) encoding / decoding round‑trip + * • Merkle tree path generation and verification + * + * The arbitraries are deliberately bounded to keep the test suite fast while + * still exercising a wide range of inputs. + */ + +import fc from "fast-check"; +import { Note } from "../src/note"; +import { Witness } from "../src/proof"; +import { MerkleTree, MerklePath } from "../src/merkle"; + +describe("SDK property‑based tests", () => { + /** + * NOTE: The concrete API of `Note` may differ slightly in the repository. + * Adjust the constructor / (de)serialization calls if necessary. + */ + test("Note serialization round‑trip", () => { + fc.assert( + fc.property( + // 32‑byte owner identifier (e.g., public key) + fc.uint8Array({ minLength: 32, maxLength: 32 }), + // Amount as a 64‑bit unsigned integer (represented as bigint) + fc.bigUintN(64), + // Token identifier – a short ASCII string + fc.string({ minLength: 1, maxLength: 10, characters: "ascii" }), + (owner, amount, token) => { + // Construct a note with the generated fields + const note = new Note({ + owner, + amount: amount.toString(), + token, + }); + + // Serialize to a Uint8Array and deserialize back + const serialized = note.serialize(); + const deserialized = Note.deserialize(serialized); + + // The SDK provides an equality helper; fall back to field‑wise compare + // if `equals` is not present. + const equal = + typeof deserialized.equals === "function" + ? deserialized.equals(note) + : deserialized.owner.every((b, i) => b === note.owner[i]) && + deserialized.amount === note.amount && + deserialized.token === note.token; + + expect(equal).toBe(true); + } + ), + { + // Keep the run count modest for CI speed; the seed makes failures reproducible. + numRuns: 200, + seed: 0xdeadbeef, + } + ); + }); + + test("Witness encoding round‑trip", () => { + fc.assert( + fc.property( + // An array of sibling hashes (each 32 bytes). Length corresponds to tree depth. + fc.array(fc.uint8Array({ minLength: 32, maxLength: 32 }), { + minLength: 1, + maxLength: 20, + }), + (siblings) => { + const witness = new Witness(siblings); + const encoded = witness.encode(); + const decoded = Witness.decode(encoded); + + const equal = + typeof decoded.equals === "function" + ? decoded.equals(witness) + : decoded.siblings.length === witness.siblings.length && + decoded.siblings.every((s, i) => + s.every((b, j) => b === witness.siblings[i][j]) + ); + + expect(equal).toBe(true); + } + ), + { + numRuns: 200, + seed: 0xcafebabe, + } + ); + }); + + test("Merkle path generation and verification", () => { + fc.assert( + fc.property( + // Random leaf values (each 32 bytes) + fc.array(fc.uint8Array({ minLength: 32, maxLength: 32 }), { + minLength: 2, + maxLength: 30, + }), + // Index selector – we will modulo by leaf count to stay in range + fc.nat(), + (leaves, rawIdx) => { + const tree = MerkleTree.fromLeaves(leaves); + const idx = rawIdx % leaves.length; + + // Generate the Merkle path for the chosen leaf + const path: MerklePath = tree.getPath(idx); + + // Verify that the path reconstructs the root + const leaf = leaves[idx]; + const computedRoot = MerklePath.verify(leaf, path, tree.depth); + + expect(computedRoot).toEqual(tree.root()); + } + ), + { + numRuns: 200, + seed: 0xfeedface, + } + ); + }); +});