Skip to content
Closed
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
55 changes: 55 additions & 0 deletions .github/workflows/zk-validation.yml
Original file line number Diff line number Diff line change
@@ -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
51 changes: 1 addition & 50 deletions artifacts/zk/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
41 changes: 41 additions & 0 deletions scripts/run_zk_validation.sh
Original file line number Diff line number Diff line change
@@ -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"
94 changes: 94 additions & 0 deletions scripts/validate_artifacts.js
Original file line number Diff line number Diff line change
@@ -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);
126 changes: 126 additions & 0 deletions sdk/tests/property.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}
);
});
});