Skip to content

Commit c9ece53

Browse files
authored
Merge pull request #421 from Anthony-19/create-release-bundle
Add deployment preflight checks that compare target pool VK metadata …
2 parents 07f8fdb + 982bc35 commit c9ece53

10 files changed

Lines changed: 606 additions & 11 deletions

docs/zk_artifacts_layout.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ artifacts/
2121
│ └── merkle.json # Compiled circuit (ACIR + ABI)
2222
├── manifests/ # Circuit metadata and checksums
2323
│ └── manifest.json # Global manifest for all circuits
24+
├── bundles/ # Releasable bundle metadata
25+
│ └── release-bundle.json # Bundle tying manifest, schema, and contract metadata together
2426
├── fixtures/ # Test vectors and golden inputs
2527
│ ├── commitment/
2628
│ │ └── test_vectors.json # Test vectors for commitment circuit
@@ -63,6 +65,10 @@ Artifact versions are tracked via the `version` field in `sdk/src/artifacts.ts`.
6365
- Circuit metadata (paths, checksums, configuration)
6466
- Root depth for Merkle trees
6567

68+
### Release Bundles (`bundles/`)
69+
70+
- **`release-bundle.json`**: Deterministic release envelope containing the manifest hash, verifier schema, and contract-facing metadata for a single coherent ZK release.
71+
6672
### Fixtures (`fixtures/`)
6773

6874
- **`test_vectors.json`**: Golden test vectors and inputs for circuit testing. Used by SDK tests to verify circuit behavior.

scripts/generate_commitment_vectors.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { createRequire } from 'node:module';
55

66
const __dirname = path.dirname(fileURLToPath(import.meta.url));
77
const repoRoot = path.resolve(__dirname, '..');
8+
const zkVersion = process.argv[2] || '1';
9+
const versionedArtifactsDir = path.join(repoRoot, 'artifacts', 'zk', `v${zkVersion}`);
810
const requireFromSdk = createRequire(path.join(repoRoot, 'sdk', 'package.json'));
911
const { poseidon2Hash } = requireFromSdk('@zkpassport/poseidon2');
1012

@@ -13,6 +15,8 @@ const FIELD_MODULUS =
1315
const NOTE_SCALAR_BYTE_LENGTH = 31;
1416
const FIELD_BYTE_LENGTH = 32;
1517

18+
const jsonPath = path.join(repoRoot, 'artifacts', 'zk', 'commitment_vectors.json');
19+
const versionedJsonPath = path.join(versionedArtifactsDir, 'commitment_vectors.json');
1620
// ZK-086: Accept version parameter for versioned artifact layout
1721
const zkVersion = process.argv[2] || '1';
1822
const jsonPath = path.join(repoRoot, 'artifacts', 'zk', `v${zkVersion}`, 'commitment_vectors.json');
@@ -187,11 +191,17 @@ pub fn fixture_cv_004() -> (Field, Field, Field, Field) {
187191
}
188192
`;
189193

194+
if (!fs.existsSync(versionedArtifactsDir)) {
195+
fs.mkdirSync(versionedArtifactsDir, { recursive: true });
196+
}
197+
190198
if (!fs.existsSync(path.dirname(jsonPath))) {
191199
fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
192200
}
193201
fs.writeFileSync(jsonPath, JSON.stringify(fixtureJson, null, 2) + '\n');
202+
fs.writeFileSync(versionedJsonPath, JSON.stringify(fixtureJson, null, 2) + '\n');
194203
fs.writeFileSync(noirFixturesPath, noirFixtures);
195204

196205
console.log(`Wrote ${path.relative(repoRoot, jsonPath)}`);
206+
console.log(`Wrote ${path.relative(repoRoot, versionedJsonPath)}`);
197207
console.log(`Wrote ${path.relative(repoRoot, noirFixturesPath)}`);

scripts/rebuild-zk.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ echo "📦 Compiling circuits..."
4444
for pkg in commitment withdraw merkle; do
4545
echo " → Building $pkg..."
4646
(cd "circuits" && nargo compile --package "$pkg")
47+
cp "circuits/target/$pkg.json" "$ARTIFACTS_DIR/circuits/$pkg/$pkg.json"
48+
cp "circuits/target/$pkg.json" "artifacts/zk/$pkg.json"
49+
done
50+
51+
echo "🧪 Regenerating shared commitment vectors..."
4752
# ZK-086: Align with versioned layout contract
4853
cp "circuits/target/$pkg.json" "$ARTIFACTS_DIR/circuits/$pkg/$pkg.json"
4954
done

scripts/refresh_manifest.mjs

Lines changed: 105 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ const repoRoot = path.resolve(__dirname, '..');
1010
// ZK-041: Accept version parameter for versioned artifact layout
1111
const zkVersion = process.argv[2] || '1';
1212
const artifactsDir = path.join(repoRoot, 'artifacts', 'zk', `v${zkVersion}`);
13-
const manifestPath = path.join(artifactsDir, 'manifests', 'manifest.json');
13+
const versionedManifestPath = path.join(artifactsDir, 'manifests', 'manifest.json');
14+
const legacyManifestPath = path.join(repoRoot, 'artifacts', 'zk', 'manifest.json');
15+
const releaseBundlePath = path.join(artifactsDir, 'bundles', 'release-bundle.json');
1416
const PRODUCTION_MERKLE_ROOT_DEPTH = 20;
15-
const CIRCUIT_ORDER = ['withdraw', 'commitment'];
1617
const WITHDRAW_PUBLIC_INPUT_SCHEMA = [
1718
'pool_id',
1819
'root',
@@ -22,6 +23,7 @@ const WITHDRAW_PUBLIC_INPUT_SCHEMA = [
2223
'relayer',
2324
'fee',
2425
];
26+
const CONTRACT_PUBLIC_INPUT_SCHEMA = WITHDRAW_PUBLIC_INPUT_SCHEMA.slice(1);
2527
const EXTRA_FILES = {
2628
commitment_vectors: {
2729
path: 'commitment_vectors.json',
@@ -66,10 +68,36 @@ function commandOutput(command, args = ['--version']) {
6668
return result.stdout;
6769
}
6870

69-
function readJson(filePath) {
70-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
71+
function detectBackendVersions() {
72+
try {
73+
return {
74+
nargo_version: commandOutput('nargo', ['--version']).trim(),
75+
noirc_version: commandOutput('noirc', ['--version']).trim(),
76+
};
77+
} catch {
78+
return {
79+
nargo_version: 'unknown',
80+
noirc_version: 'unknown',
81+
};
82+
}
7183
}
7284

85+
function normalizeBackend(backend) {
86+
const versions = detectBackendVersions();
87+
if (backend && typeof backend === 'object') {
88+
return {
89+
name: backend.name ?? 'nargo/noir',
90+
nargo_version: backend.nargo_version ?? versions.nargo_version,
91+
noirc_version: backend.noirc_version ?? versions.noirc_version,
92+
};
93+
}
94+
95+
return {
96+
name: typeof backend === 'string' && backend.length > 0 ? backend : 'nargo/noir',
97+
nargo_version: versions.nargo_version,
98+
noirc_version: versions.noirc_version,
99+
};
100+
}
73101

74102

75103
function buildExtraFileEntries() {
@@ -92,6 +120,36 @@ function buildExtraFileEntries() {
92120
);
93121
}
94122

123+
function buildReleaseBundle(manifest) {
124+
const withdraw = manifest.circuits.withdraw;
125+
if (!withdraw || !withdraw.public_input_schema) {
126+
throw new Error('Withdrawal circuit manifest entry is required to build the release bundle');
127+
}
128+
129+
const manifestSha256 = sha256Hex(stableStringify(manifest));
130+
const verifierSchema = {
131+
circuit_id: withdraw.circuit_id,
132+
public_input_schema: withdraw.public_input_schema,
133+
public_input_arity: withdraw.public_input_schema.length,
134+
contract_public_input_schema: CONTRACT_PUBLIC_INPUT_SCHEMA,
135+
contract_public_input_arity: CONTRACT_PUBLIC_INPUT_SCHEMA.length,
136+
schema_version: 1,
137+
};
138+
139+
return {
140+
version: 1,
141+
artifact_version: zkVersion,
142+
manifest_sha256: manifestSha256,
143+
manifest,
144+
verifier_schema,
145+
contract_metadata: {
146+
contract_name: 'privacy_pool',
147+
target_circuit_id: withdraw.circuit_id,
148+
manifest_sha256: manifestSha256,
149+
public_input_arity: CONTRACT_PUBLIC_INPUT_SCHEMA.length,
150+
schema_version: 1,
151+
verifier_key_storage: 'DataKey::VerifyingKey',
152+
},
95153
function computeChecksums(raw, artifact) {
96154
return {
97155
artifact_sha256: sha256Hex(raw),
@@ -103,16 +161,20 @@ function computeChecksums(raw, artifact) {
103161
function main() {
104162
console.log(`Refreshing ZK manifest for version ${zkVersion}...`);
105163
106-
// ZK-041: Create manifests directory if it doesn't exist
107-
if (!fs.existsSync(path.dirname(manifestPath))) {
108-
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
164+
if (!fs.existsSync(path.dirname(versionedManifestPath))) {
165+
fs.mkdirSync(path.dirname(versionedManifestPath), { recursive: true });
166+
}
167+
if (!fs.existsSync(path.dirname(releaseBundlePath))) {
168+
fs.mkdirSync(path.dirname(releaseBundlePath), { recursive: true });
109169
}
110170
111-
// ZK-041: Initialize manifest structure if it doesn't exist
112171
let manifest;
113-
if (fs.existsSync(manifestPath)) {
114-
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
172+
if (fs.existsSync(versionedManifestPath)) {
173+
manifest = JSON.parse(fs.readFileSync(versionedManifestPath, 'utf8'));
174+
} else if (fs.existsSync(legacyManifestPath)) {
175+
manifest = JSON.parse(fs.readFileSync(legacyManifestPath, 'utf8'));
115176
} else {
177+
throw new Error('No manifest found to refresh. Run the rebuild pipeline first.');
116178
manifest = {
117179
version: zkVersion,
118180
backend: {
@@ -124,10 +186,13 @@ function main() {
124186
};
125187
}
126188
127-
// ZK-041: Update circuits list to include merkle
189+
manifest.version = Number.parseInt(zkVersion, 10);
190+
manifest.backend = normalizeBackend(manifest.backend);
191+
128192
const circuits = ['withdraw', 'commitment', 'merkle'];
129193
130194
for (const name of circuits) {
195+
const filePath = path.join(artifactsDir, 'circuits', name, `${name}.json`);
131196
// ZK-041: Look for circuits in versioned directory structure
132197
const circuitFile = `circuits/${name}/${name}.json`;
133198
const filePath = path.join(artifactsDir, circuitFile);
@@ -139,6 +204,35 @@ function main() {
139204
140205
const raw = fs.readFileSync(filePath);
141206
const artifact = JSON.parse(raw.toString('utf8'));
207+
const circuitEntry = manifest.circuits[name] ?? (manifest.circuits[name] = {});
208+
circuitEntry.circuit_id = name;
209+
circuitEntry.path = `circuits/${name}/${name}.json`;
210+
circuitEntry.artifact_sha256 = sha256Hex(raw);
211+
circuitEntry.bytecode_sha256 = sha256Hex(String(artifact.bytecode ?? ''));
212+
circuitEntry.abi_sha256 = sha256Hex(stableStringify(artifact.abi ?? null));
213+
circuitEntry.name = artifact.name ?? name;
214+
circuitEntry.backend = 'nargo/noir';
215+
216+
if (name === 'withdraw') {
217+
circuitEntry.root_depth = PRODUCTION_MERKLE_ROOT_DEPTH;
218+
circuitEntry.public_input_schema = WITHDRAW_PUBLIC_INPUT_SCHEMA;
219+
}
220+
}
221+
222+
const extraFiles = buildExtraFileEntries();
223+
manifest.files = extraFiles;
224+
225+
const manifestText = JSON.stringify(manifest, null, 2) + '\n';
226+
const releaseBundle = buildReleaseBundle(manifest);
227+
const releaseBundleText = JSON.stringify(releaseBundle, null, 2) + '\n';
228+
229+
fs.writeFileSync(versionedManifestPath, manifestText);
230+
fs.writeFileSync(legacyManifestPath, manifestText);
231+
fs.writeFileSync(releaseBundlePath, releaseBundleText);
232+
233+
console.log(`Manifest updated at ${versionedManifestPath}`);
234+
console.log(`Legacy manifest updated at ${legacyManifestPath}`);
235+
console.log(`Release bundle updated at ${releaseBundlePath}`);
142236
const checksums = computeChecksums(raw, artifact);
143237
144238
if (!manifest.circuits[name]) {

scripts/zk_release_preflight.mjs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
8+
const repoRoot = path.resolve(__dirname, '..');
9+
10+
function fail(message) {
11+
console.error(message);
12+
process.exit(1);
13+
}
14+
15+
function parseArgs(argv) {
16+
const options = {
17+
version: '1',
18+
bundlePath: '',
19+
targetMetadataPath: '',
20+
targetMetadataJson: '',
21+
};
22+
23+
for (let i = 0; i < argv.length; i += 1) {
24+
const arg = argv[i];
25+
if (arg === '--version') {
26+
options.version = argv[++i] ?? '1';
27+
} else if (arg === '--bundle-path') {
28+
options.bundlePath = argv[++i] ?? '';
29+
} else if (arg === '--target-metadata-path') {
30+
options.targetMetadataPath = argv[++i] ?? '';
31+
} else if (arg === '--target-metadata-json') {
32+
options.targetMetadataJson = argv[++i] ?? '';
33+
} else if (arg === '--help' || arg === '-h') {
34+
options.help = true;
35+
} else {
36+
fail(`Unknown argument: ${arg}`);
37+
}
38+
}
39+
40+
return options;
41+
}
42+
43+
function printHelp() {
44+
console.log([
45+
'Usage:',
46+
' node scripts/zk_release_preflight.mjs --version 1 --target-metadata-path pool-config.json',
47+
' node scripts/zk_release_preflight.mjs --bundle-path artifacts/zk/v1/bundles/release-bundle.json --target-metadata-json "{...}"',
48+
'',
49+
'Target metadata must contain:',
50+
' circuit_id, manifest_sha256, public_input_arity',
51+
'',
52+
'Optional target metadata field:',
53+
' schema_version',
54+
].join('\n'));
55+
}
56+
57+
function readJson(filePath) {
58+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
59+
}
60+
61+
function resolveBundlePath(options) {
62+
if (options.bundlePath) {
63+
return path.isAbsolute(options.bundlePath)
64+
? options.bundlePath
65+
: path.join(repoRoot, options.bundlePath);
66+
}
67+
68+
return path.join(repoRoot, 'artifacts', 'zk', `v${options.version}`, 'bundles', 'release-bundle.json');
69+
}
70+
71+
function loadBundle(bundlePath) {
72+
if (!fs.existsSync(bundlePath)) {
73+
fail(`Release bundle not found: ${bundlePath}`);
74+
}
75+
76+
return readJson(bundlePath);
77+
}
78+
79+
function loadTargetMetadata(options) {
80+
if (options.targetMetadataJson) {
81+
return JSON.parse(options.targetMetadataJson);
82+
}
83+
84+
if (options.targetMetadataPath) {
85+
const targetPath = path.isAbsolute(options.targetMetadataPath)
86+
? options.targetMetadataPath
87+
: path.join(repoRoot, options.targetMetadataPath);
88+
89+
if (!fs.existsSync(targetPath)) {
90+
fail(`Target metadata file not found: ${targetPath}`);
91+
}
92+
93+
return readJson(targetPath);
94+
}
95+
96+
fail('Provide either --target-metadata-path or --target-metadata-json.');
97+
}
98+
99+
function compareBundleToTarget(bundle, target) {
100+
const expected = bundle.verifier_schema ?? {};
101+
const contractMetadata = bundle.contract_metadata ?? {};
102+
const mismatches = [];
103+
104+
if (target.circuit_id !== expected.circuit_id) {
105+
mismatches.push({ field: 'circuit_id', expected: expected.circuit_id, actual: target.circuit_id });
106+
}
107+
108+
if (target.manifest_sha256 !== contractMetadata.manifest_sha256) {
109+
mismatches.push({ field: 'manifest_sha256', expected: contractMetadata.manifest_sha256, actual: target.manifest_sha256 });
110+
}
111+
112+
if (target.public_input_arity !== expected.contract_public_input_arity) {
113+
mismatches.push({ field: 'public_input_arity', expected: expected.contract_public_input_arity, actual: target.public_input_arity });
114+
}
115+
116+
if (
117+
typeof target.schema_version === 'number' &&
118+
target.schema_version !== expected.schema_version
119+
) {
120+
mismatches.push({ field: 'schema_version', expected: expected.schema_version, actual: target.schema_version });
121+
}
122+
123+
return mismatches;
124+
}
125+
126+
function main() {
127+
const options = parseArgs(process.argv.slice(2));
128+
if (options.help) {
129+
printHelp();
130+
return;
131+
}
132+
133+
const bundlePath = resolveBundlePath(options);
134+
const bundle = loadBundle(bundlePath);
135+
const target = loadTargetMetadata(options);
136+
137+
const mismatches = compareBundleToTarget(bundle, target);
138+
console.log(`Release bundle: ${bundlePath}`);
139+
console.log(`Target circuit: ${target.circuit_id}`);
140+
141+
if (mismatches.length > 0) {
142+
console.error('Release preflight failed:');
143+
for (const mismatch of mismatches) {
144+
console.error(`- ${mismatch.field}: expected ${mismatch.expected}, got ${mismatch.actual}`);
145+
}
146+
process.exit(1);
147+
}
148+
149+
console.log('Release preflight passed.');
150+
}
151+
152+
main();

0 commit comments

Comments
 (0)