Skip to content
Open
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
1 change: 1 addition & 0 deletions contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"compile": "node scripts/compile.js",
"deploy:local": "npm run compile && HOME=$PWD/.home XDG_CONFIG_HOME=$PWD/.xdg/config XDG_DATA_HOME=$PWD/.xdg/data npx hardhat run --no-compile scripts/runDeployLocal.js --network hardhat",
"deploy:edge": "npm run compile && node scripts/runDeployEdge.js",
"test:zk": "node scripts/compileZk.js && npx hardhat test tests/ZkAuditVerifier.test.js --no-compile",
"test": "npm run compile && HOME=$PWD/.home XDG_CONFIG_HOME=$PWD/.xdg/config XDG_DATA_HOME=$PWD/.xdg/data npx hardhat test --no-compile"
},
"devDependencies": {
Expand Down
103 changes: 103 additions & 0 deletions contracts/scripts/compileZk.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const fs = require("fs");
const path = require("path");

let solc;

try {
solc = require("solc");
} catch (error) {
console.error(
[
"Missing Solidity compiler dependency: solc",
"Run `npm install` inside contracts/ once npm registry access is available."
].join("\n")
);
process.exit(1);
}

const sourceDir = path.join(__dirname, "..", "src");
const artifactDir = path.join(__dirname, "..", "artifacts");

const CONTRACTS = [
{ name: "MockGroth16Verifier", file: "mocks/MockGroth16Verifier.sol" },
{ name: "ZkAuditVerifier", file: "ZkAuditVerifier.sol" }
];

function formatCompilerVersion(version) {
const match = /^(\d+\.\d+\.\d+)/.exec(version);
return match ? match[1] : version;
}

function compileAll() {
const sources = {};
for (const contract of CONTRACTS) {
const sourcePath = path.join(sourceDir, contract.file);
sources[`src/${contract.file}`] = {
content: fs.readFileSync(sourcePath, "utf8")
};
}

const input = {
language: "Solidity",
sources,
settings: {
optimizer: { enabled: true, runs: 200 },
evmVersion: "paris",
viaIR: true,
outputSelection: {
"*": {
"*": ["abi", "evm.bytecode.object", "evm.deployedBytecode.object", "metadata"]
}
}
}
};

return JSON.parse(solc.compile(JSON.stringify(input)));
}

function assertNoCompilerErrors(output) {
const messages = output.errors ?? [];
const fatalErrors = messages.filter((entry) => entry.severity === "error");

for (const message of messages) {
console.error(message.formattedMessage ?? message.message);
}

if (fatalErrors.length > 0) {
process.exit(1);
}
}

function writeArtifact(output, contractFile, contractName) {
const contractOutput = output.contracts?.[`src/${contractFile}`]?.[contractName];

if (!contractOutput) {
throw new Error(`${contractName} output was not produced`);
}

const artifact = {
contractName,
sourceName: `src/${contractFile}`,
abi: contractOutput.abi,
bytecode: `0x${contractOutput.evm.bytecode.object}`,
deployedBytecode: `0x${contractOutput.evm.deployedBytecode.object}`,
compiler: { version: formatCompilerVersion(solc.version()) },
metadata: JSON.parse(contractOutput.metadata)
};

fs.mkdirSync(artifactDir, { recursive: true });
const artifactPath = path.join(artifactDir, `${contractName}.json`);
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`);
console.log(`Compiled ${contractName} → ${artifactPath}`);
}

function main() {
const output = compileAll();
assertNoCompilerErrors(output);

for (const contract of CONTRACTS) {
writeArtifact(output, contract.file, contract.name);
}
}

main();
74 changes: 41 additions & 33 deletions contracts/src/ZkAuditVerifier.sol
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ contract ZkAuditVerifier {
// tokenId => number of verified audit proofs
mapping(uint256 => uint256) public auditProofCount;

// Global dedup: each Groth16 proof may only be consumed once
mapping(bytes32 => bool) private _usedProofHashes;

// --- Modifiers ---

modifier onlyOwner() {
Expand Down Expand Up @@ -108,7 +111,9 @@ contract ZkAuditVerifier {
* @param dimensionalScores The 6 claimed dimensional scores [0-100]
* @param overallScore The claimed weighted overall score [0-100]
* @param inputCommitment Poseidon hash binding the proof to private inputs
* @param proof The Groth16 proof (a, b, c points)
* @param a Groth16 proof component A
* @param b Groth16 proof component B
* @param c Groth16 proof component C
*/
function verifyAuditScore(
uint256 tokenId,
Expand All @@ -131,17 +136,17 @@ contract ZkAuditVerifier {
pubInputs[7] = inputCommitment;

// Call the Groth16 verifier
bool valid = _callGroth16Verifier(
bool valid = _callGroth16Verifier8(
auditScoreVerifierContract,
a, b, c,
pubInputs,
8
pubInputs
);

require(valid, "INVALID_PROOF");

// Store the verified proof
bytes32 proofHash = keccak256(abi.encodePacked(a[0], a[1], b[0][0], b[0][1], c[0], c[1]));
require(!_usedProofHashes[proofHash], "PROOF_REPLAYED");
_usedProofHashes[proofHash] = true;

auditScoreProofs[tokenId][auditId] = AuditScoreProof({
tokenId: tokenId,
Expand All @@ -168,7 +173,9 @@ contract ZkAuditVerifier {
* @param tokenId The agent's NFT token ID
* @param fingerprintHash The public fingerprint hash
* @param developerHash The developer ownership hash
* @param proof The Groth16 proof (a, b, c points)
* @param a Groth16 proof component A
* @param b Groth16 proof component B
* @param c Groth16 proof component C
*/
function verifyFingerprint(
uint256 tokenId,
Expand All @@ -186,16 +193,17 @@ contract ZkAuditVerifier {
pubInputs[1] = tokenId;
pubInputs[2] = developerHash;

bool valid = _callGroth16Verifier(
bool valid = _callGroth16Verifier3(
fingerprintVerifierContract,
a, b, c,
pubInputs,
3
pubInputs
);

require(valid, "INVALID_PROOF");

bytes32 proofHash = keccak256(abi.encodePacked(a[0], a[1], b[0][0], b[0][1], c[0], c[1]));
require(!_usedProofHashes[proofHash], "PROOF_REPLAYED");
_usedProofHashes[proofHash] = true;

fingerprintProofs[tokenId] = AgentFingerprintProof({
tokenId: tokenId,
Expand Down Expand Up @@ -239,36 +247,36 @@ contract ZkAuditVerifier {

// --- Internal: Generic Groth16 Verifier Call ---

function _callGroth16Verifier(
function _callGroth16Verifier8(
address verifier,
uint256[2] calldata a,
uint256[2][2] calldata b,
uint256[2] calldata c,
uint256[8] memory pubInputs,
uint256 numInputs
uint256[8] memory pubInputs
) internal view returns (bool) {
// snarkjs-generated verifiers expose:
// verifyProof(uint[2] a, uint[2][2] b, uint[2] c, uint[N] input) → bool
// We encode the call dynamically based on numInputs
bytes memory payload;

if (numInputs == 8) {
payload = abi.encodeWithSignature(
"verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[8])",
a, b, c, pubInputs
);
} else if (numInputs == 3) {
uint256[3] memory inputs3;
for (uint256 i = 0; i < 3; i++) {
inputs3[i] = pubInputs[i];
}
payload = abi.encodeWithSignature(
"verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[3])",
a, b, c, inputs3
);
} else {
revert("UNSUPPORTED_INPUT_COUNT");
bytes memory payload = abi.encodeWithSignature(
"verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[8])",
a, b, c, pubInputs
);

(bool success, bytes memory result) = verifier.staticcall(payload);
if (!success || result.length < 32) {
return false;
}
return abi.decode(result, (bool));
}

function _callGroth16Verifier3(
address verifier,
uint256[2] calldata a,
uint256[2][2] calldata b,
uint256[2] calldata c,
uint256[3] memory pubInputs
) internal view returns (bool) {
bytes memory payload = abi.encodeWithSignature(
"verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[3])",
a, b, c, pubInputs
);

(bool success, bytes memory result) = verifier.staticcall(payload);
if (!success || result.length < 32) {
Expand Down
26 changes: 26 additions & 0 deletions contracts/src/mocks/MockGroth16Verifier.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/**
* @title MockGroth16Verifier
* @notice Test-only Groth16 verifier that accepts all proofs.
*/
contract MockGroth16Verifier {
function verifyProof(
uint256[2] calldata,
uint256[2][2] calldata,
uint256[2] calldata,
uint256[8] calldata
) external pure returns (bool) {
return true;
}

function verifyProof(
uint256[2] calldata,
uint256[2][2] calldata,
uint256[2] calldata,
uint256[3] calldata
) external pure returns (bool) {
return true;
}
}
122 changes: 122 additions & 0 deletions contracts/tests/ZkAuditVerifier.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");

const { ethers } = require("hardhat");

function loadArtifact(name) {
const flatPath = path.join(__dirname, "..", "artifacts", `${name}.json`);
if (fs.existsSync(flatPath)) {
return JSON.parse(fs.readFileSync(flatPath, "utf8"));
}
const hardhatPath = path.join(__dirname, "..", "artifacts", "src", `${name}.sol`, `${name}.json`);
return JSON.parse(fs.readFileSync(hardhatPath, "utf8"));
}

const PROOF_A = [1, 2];
const PROOF_B = [
[3, 4],
[5, 6]
];
const PROOF_C = [7, 8];

const DIMENSIONAL_SCORES = [80, 85, 90, 75, 88, 92];
const OVERALL_SCORE = 85;
const INPUT_COMMITMENT = 12345;

async function deployZkAuditVerifier() {
const [owner] = await ethers.getSigners();

const mockArtifact = loadArtifact("MockGroth16Verifier");
const mockFactory = new ethers.ContractFactory(mockArtifact.abi, mockArtifact.bytecode, owner);
const mockVerifier = await mockFactory.deploy();
await mockVerifier.deployed();

const verifierArtifact = loadArtifact("ZkAuditVerifier");
const verifierFactory = new ethers.ContractFactory(verifierArtifact.abi, verifierArtifact.bytecode, owner);
const verifier = await verifierFactory.deploy();
await verifier.deployed();

await (await verifier.setAuditScoreVerifier(mockVerifier.address)).wait();
await (await verifier.setFingerprintVerifier(mockVerifier.address)).wait();

return { verifier, owner };
}

function buildAuditScoreArgs(tokenId, auditId) {
return [
tokenId,
auditId,
DIMENSIONAL_SCORES,
OVERALL_SCORE,
INPUT_COMMITMENT,
PROOF_A,
PROOF_B,
PROOF_C
];
}

function buildFingerprintArgs(tokenId, fingerprintHash, developerHash) {
return [tokenId, fingerprintHash, developerHash, PROOF_A, PROOF_B, PROOF_C];
}

describe("ZkAuditVerifier — proof replay protection", function () {
it("verifyAuditScore stores the first proof and increments auditProofCount", async function () {
const { verifier } = await deployZkAuditVerifier();

await (await verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1))).wait();

assert.strictEqual(await verifier.isAuditScoreVerified(1, 1), true);
assert.strictEqual((await verifier.auditProofCount(1)).toNumber(), 1);

const stored = await verifier.getAuditScoreProof(1, 1);
assert.strictEqual(stored.overallScore.toNumber(), OVERALL_SCORE);
assert.strictEqual(stored.verified, true);
});

it("rejects replaying the same audit score proof for the same tokenId and auditId", async function () {
const { verifier } = await deployZkAuditVerifier();

await (await verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1))).wait();

await assert.rejects(
verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1)),
/PROOF_REPLAYED/
);

assert.strictEqual((await verifier.auditProofCount(1)).toNumber(), 1);
});

it("rejects replaying the same audit score proof for a different tokenId and auditId", async function () {
const { verifier } = await deployZkAuditVerifier();

await (await verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1))).wait();

await assert.rejects(
verifier.verifyAuditScore(...buildAuditScoreArgs(2, 2)),
/PROOF_REPLAYED/
);

assert.strictEqual(await verifier.isAuditScoreVerified(2, 2), false);
assert.strictEqual((await verifier.auditProofCount(2)).toNumber(), 0);
});

it("rejects replaying the same fingerprint proof", async function () {
const { verifier } = await deployZkAuditVerifier();

await (await verifier.verifyFingerprint(...buildFingerprintArgs(1, 111, 222))).wait();
assert.strictEqual(await verifier.isFingerprintVerified(1), true);

await assert.rejects(
verifier.verifyFingerprint(...buildFingerprintArgs(1, 111, 222)),
/PROOF_REPLAYED/
);

await assert.rejects(
verifier.verifyFingerprint(...buildFingerprintArgs(2, 333, 444)),
/PROOF_REPLAYED/
);

assert.strictEqual(await verifier.isFingerprintVerified(2), false);
});
});