qp_wormhole_circuit_builder/
lib.rs1use anyhow::{anyhow, bail, Context, Result};
14use std::fs;
15use std::fs::create_dir_all;
16use std::path::{Path, PathBuf};
17use wormhole_aggregator::common::utils::commit_artifact_set;
18use wormhole_aggregator::public_batch::circuit::generate_public_batch_circuit_binaries;
19
20use plonky2::util::serialization::DefaultGateSerializer;
21use wormhole_aggregator::private_batch::circuit::build::generate_private_batch_circuit_binaries;
22use wormhole_circuit::circuit::circuit_logic::WormholeCircuit;
23use zk_circuits_common::circuit::wormhole_leaf_circuit_config;
24
25pub use wormhole_aggregator::CircuitBinsConfig;
27
28pub fn generate_circuit_binaries<P: AsRef<Path>>(output_dir: P) -> Result<()> {
38 println!(
39 "Building wormhole leaf circuit (non-ZK by design; ZK lives at the private-batch layer)..."
40 );
41 let config = wormhole_leaf_circuit_config();
42 let circuit = WormholeCircuit::new(config)?;
43 let targets = circuit.targets();
44 let circuit_data = circuit.build_circuit();
45 println!("Circuit built.");
46
47 let gate_serializer = DefaultGateSerializer;
48
49 let output_path = output_dir.as_ref();
50 create_dir_all(output_path)?;
51
52 println!("Generating dummy proof for aggregation padding...");
54 let dummy_proof_bytes = wormhole_aggregator::generate_dummy_proof(&circuit_data, &targets)
55 .map_err(|e| anyhow!("failed to generate dummy proof: {}", e))?;
56
57 println!("Serializing circuit data...");
58
59 let verifier_data = circuit_data.verifier_data();
60 let common_data = &verifier_data.common;
61
62 let common_bytes = common_data
63 .to_bytes(&gate_serializer)
64 .map_err(|e| anyhow!("failed to serialize common data: {}", e))?;
65 let verifier_only_bytes = verifier_data
66 .verifier_only
67 .to_bytes()
68 .map_err(|e| anyhow!("failed to serialize verifier data: {}", e))?;
69
70 println!(
71 "Publishing {}/dummy_proof.bin ({} bytes)",
72 output_path.display(),
73 dummy_proof_bytes.len()
74 );
75 commit_artifact_set(
76 output_path,
77 &[
78 ("dummy_proof.bin", dummy_proof_bytes),
79 ("common.bin", common_bytes),
80 ("verifier.bin", verifier_only_bytes),
81 ],
82 &[],
83 )?;
84 println!("Common data saved to {}/common.bin", output_path.display());
85 println!(
86 "Verifier data saved to {}/verifier.bin",
87 output_path.display()
88 );
89
90 Ok(())
91}
92
93pub fn generate_all_circuit_binaries<P: AsRef<Path>>(
116 output_dir: P,
117 include_prover: bool,
118 num_leaf_proofs: usize,
119 num_private_batch_proofs: Option<usize>,
120) -> Result<()> {
121 let config = CircuitBinsConfig::new(num_leaf_proofs, num_private_batch_proofs)?;
123
124 let output_path = output_dir.as_ref();
125 let staging_path = create_staging_dir(output_path)?;
126
127 let generated = (|| -> Result<()> {
128 generate_circuit_binaries(&staging_path)?;
130
131 generate_private_batch_circuit_binaries(
133 &staging_path,
134 config.num_leaf_proofs,
135 include_prover,
136 )?;
137
138 if let Some(num_private_batch_proofs) = config.num_private_batch_proofs {
140 generate_public_batch_circuit_binaries(
141 &staging_path,
142 num_private_batch_proofs,
143 config.num_leaf_proofs,
144 )?;
145 }
146
147 config.save(&staging_path)
150 })();
151
152 if let Err(e) = generated {
153 let _ = fs::remove_dir_all(&staging_path);
158 return Err(e);
159 }
160
161 commit_staging_dir(&staging_path, output_path)
162}
163
164fn create_staging_dir(output_dir: &Path) -> Result<PathBuf> {
168 let Some(name) = output_dir.file_name().and_then(|n| n.to_str()) else {
169 bail!(
170 "output dir {} has no usable directory name; pass an explicit directory path",
171 output_dir.display()
172 );
173 };
174 if let Some(parent) = output_dir.parent() {
175 if !parent.as_os_str().is_empty() {
176 create_dir_all(parent).with_context(|| {
177 format!(
178 "failed to create output parent directory {}",
179 parent.display()
180 )
181 })?;
182 }
183 }
184 for _ in 0..8 {
185 let candidate = output_dir.with_file_name(format!(
186 ".{}.staging-{}-{:016x}",
187 name,
188 std::process::id(),
189 rand::random::<u64>()
190 ));
191 match fs::create_dir(&candidate) {
192 Ok(()) => return Ok(candidate),
193 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
194 Err(e) => {
195 return Err(e).with_context(|| {
196 format!("failed to create staging directory {}", candidate.display())
197 })
198 }
199 }
200 }
201 bail!(
202 "failed to create a fresh staging directory next to {}; \
203 remove stale .{}.staging-* entries and retry",
204 output_dir.display(),
205 name
206 );
207}
208
209fn commit_staging_dir(staging_dir: &Path, output_dir: &Path) -> Result<()> {
218 commit_staging_dir_impl(staging_dir, output_dir, |src, dst| fs::rename(src, dst))
219}
220
221fn commit_staging_dir_impl(
223 staging_dir: &Path,
224 output_dir: &Path,
225 rename: impl Fn(&Path, &Path) -> std::io::Result<()>,
226) -> Result<()> {
227 if !staging_dir.is_dir() {
228 bail!(
229 "staged artifact path {} is not a directory; refusing to publish",
230 staging_dir.display()
231 );
232 }
233
234 let mut old_name = staging_dir.file_name().unwrap_or_default().to_os_string();
235 old_name.push(".old");
236 let old_path = staging_dir.with_file_name(old_name);
237 let previous_exists = output_dir.exists();
238 if previous_exists {
239 if !output_dir.is_dir() {
240 let _ = fs::remove_dir_all(staging_dir);
241 bail!(
242 "output path {} exists but is not a directory; remove or rename it and retry",
243 output_dir.display()
244 );
245 }
246 if let Err(e) = rename(output_dir, &old_path) {
247 let _ = fs::remove_dir_all(staging_dir);
248 return Err(e).with_context(|| {
249 format!(
250 "Failed to move previous artifact dir {} aside to {}",
251 output_dir.display(),
252 old_path.display()
253 )
254 });
255 }
256 }
257
258 if let Err(e) = rename(staging_dir, output_dir) {
259 if previous_exists {
260 match rename(&old_path, output_dir) {
261 Ok(()) => {
262 let _ = fs::remove_dir_all(staging_dir);
263 return Err(e).with_context(|| {
264 format!(
265 "Failed to move staged artifacts {} into place at {} \
266 (previous artifacts were restored)",
267 staging_dir.display(),
268 output_dir.display()
269 )
270 });
271 }
272 Err(rollback_err) => {
273 return Err(e).with_context(|| {
274 format!(
275 "Failed to move staged artifacts {} into place at {}, and \
276 restoring the previous artifacts also failed ({}); previous \
277 artifacts remain at {}, new artifacts remain at {}",
278 staging_dir.display(),
279 output_dir.display(),
280 rollback_err,
281 old_path.display(),
282 staging_dir.display()
283 )
284 });
285 }
286 }
287 }
288 return Err(e).with_context(|| {
289 format!(
290 "Failed to move staged artifacts {} into place at {}; \
291 the built artifacts remain at {}",
292 staging_dir.display(),
293 output_dir.display(),
294 staging_dir.display()
295 )
296 });
297 }
298
299 if previous_exists {
300 if let Err(e) = fs::remove_dir_all(&old_path) {
301 eprintln!(
302 "warning: published artifacts to {}, but failed to remove the previous \
303 artifact copy at {}: {e}; remove it manually if it is no longer needed",
304 output_dir.display(),
305 old_path.display()
306 );
307 }
308 }
309 Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use std::fs::write;
316
317 fn unique_tmp_dir(tag: &str) -> PathBuf {
318 std::env::temp_dir().join(format!(
319 "qp-circuit-builder-test-{}-{}",
320 tag,
321 std::process::id()
322 ))
323 }
324
325 #[test]
328 fn commit_rejects_non_directory_previous_output() {
329 let root = unique_tmp_dir("commit-not-dir");
330 let _ = fs::remove_dir_all(&root);
331 create_dir_all(&root).unwrap();
332 let output = root.join("bins");
333 let staging = create_staging_dir(&output).unwrap();
334
335 write(&output, b"i am a file").unwrap();
336 write(staging.join("fresh.bin"), b"new artifact").unwrap();
337
338 let err = commit_staging_dir(&staging, &output).unwrap_err();
339 assert!(
340 format!("{err:#}").contains("not a directory"),
341 "got: {err:#}"
342 );
343 assert_eq!(
344 fs::read(&output).unwrap(),
345 b"i am a file",
346 "previous non-directory must be left untouched"
347 );
348 assert!(
349 !staging.exists(),
350 "rejected commit discards the (uncommitted) staged copy"
351 );
352
353 fs::remove_dir_all(&root).unwrap();
354 }
355
356 #[cfg(unix)]
361 #[test]
362 fn successful_publish_is_not_failed_by_old_copy_cleanup() {
363 use std::os::unix::fs::PermissionsExt;
364
365 let root = unique_tmp_dir("commit-cleanup-best-effort");
366 let _ = fs::remove_dir_all(&root);
367 create_dir_all(&root).unwrap();
368 let output = root.join("bins");
369 let staging = create_staging_dir(&output).unwrap();
370
371 create_dir_all(&output).unwrap();
372 write(output.join("stale.bin"), b"old artifact").unwrap();
373 let locked = output.join("locked");
376 create_dir_all(&locked).unwrap();
377 write(locked.join("x.bin"), b"x").unwrap();
378 let mut perms = fs::metadata(&locked).unwrap().permissions();
379 perms.set_mode(0o555);
380 fs::set_permissions(&locked, perms).unwrap();
381
382 write(staging.join("fresh.bin"), b"new artifact").unwrap();
383
384 let result = commit_staging_dir(&staging, &output);
385 assert!(
386 result.is_ok(),
387 "publish must report success even when old-copy cleanup fails: {result:?}"
388 );
389 assert!(
390 output.join("fresh.bin").exists(),
391 "new artifacts must be live at output_dir"
392 );
393 assert!(
394 !output.join("stale.bin").exists(),
395 "stale contents must not remain in the published dir"
396 );
397
398 for entry in fs::read_dir(&root).unwrap() {
400 let path = entry.unwrap().path();
401 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
402 if !name.ends_with(".old") {
403 continue;
404 }
405 let locked_old = path.join("locked");
406 if locked_old.exists() {
407 let mut p = fs::metadata(&locked_old).unwrap().permissions();
408 p.set_mode(0o755);
409 fs::set_permissions(&locked_old, p).unwrap();
410 }
411 }
412
413 fs::remove_dir_all(&root).unwrap();
414 }
415
416 #[test]
418 fn commit_replaces_existing_output_dir_wholesale() {
419 let root = unique_tmp_dir("commit-replace");
420 let _ = fs::remove_dir_all(&root);
421 create_dir_all(&root).unwrap();
422 let output = root.join("bins");
423 let staging = create_staging_dir(&output).unwrap();
424
425 create_dir_all(&output).unwrap();
426 write(output.join("stale.bin"), b"old artifact").unwrap();
427 write(staging.join("fresh.bin"), b"new artifact").unwrap();
428
429 commit_staging_dir(&staging, &output).unwrap();
430
431 assert!(output.join("fresh.bin").exists());
432 assert!(
433 !output.join("stale.bin").exists(),
434 "stale artifacts must not survive the swap"
435 );
436 assert!(!staging.exists(), "staging dir must be consumed");
437
438 fs::remove_dir_all(&root).unwrap();
439 }
440
441 #[test]
442 fn commit_works_when_output_dir_does_not_exist() {
443 let root = unique_tmp_dir("commit-fresh");
444 let _ = fs::remove_dir_all(&root);
445 create_dir_all(&root).unwrap();
446 let output = root.join("bins");
447 let staging = create_staging_dir(&output).unwrap();
448
449 write(staging.join("fresh.bin"), b"new artifact").unwrap();
450
451 commit_staging_dir(&staging, &output).unwrap();
452 assert!(output.join("fresh.bin").exists());
453 assert!(!staging.exists());
454
455 fs::remove_dir_all(&root).unwrap();
456 }
457
458 fn io_fail() -> std::io::Error {
459 std::io::Error::other("injected rename failure")
460 }
461
462 #[test]
466 fn failed_swap_in_restores_previous_artifacts() {
467 let root = unique_tmp_dir("swap-rollback");
468 let _ = fs::remove_dir_all(&root);
469 let output = root.join("bins");
470 let staging = create_staging_dir(&output).unwrap();
471 create_dir_all(&output).unwrap();
472 write(output.join("previous.bin"), b"previous").unwrap();
473 write(staging.join("fresh.bin"), b"fresh").unwrap();
474
475 let staging_src = staging.clone();
477 let err = commit_staging_dir_impl(&staging, &output, |src, dst| {
478 if src == staging_src {
479 Err(io_fail())
480 } else {
481 fs::rename(src, dst)
482 }
483 })
484 .unwrap_err();
485
486 assert!(format!("{err:#}").contains("previous artifacts were restored"));
487 assert!(
488 output.join("previous.bin").exists(),
489 "previous artifact set must be rolled back into place"
490 );
491 assert!(!staging.exists(), "redundant staged copy is discarded");
492
493 fs::remove_dir_all(&root).unwrap();
494 }
495
496 #[test]
499 fn failed_swap_in_with_failed_rollback_preserves_both_copies() {
500 let root = unique_tmp_dir("swap-rollback-fail");
501 let _ = fs::remove_dir_all(&root);
502 let output = root.join("bins");
503 let staging = create_staging_dir(&output).unwrap();
504 create_dir_all(&output).unwrap();
505 write(output.join("previous.bin"), b"previous").unwrap();
506 write(staging.join("fresh.bin"), b"fresh").unwrap();
507
508 let output_dst = output.clone();
510 let err = commit_staging_dir_impl(&staging, &output, |src, dst| {
511 if dst == output_dst {
512 Err(io_fail())
513 } else {
514 fs::rename(src, dst)
515 }
516 })
517 .unwrap_err();
518
519 let msg = format!("{err:#}");
520 assert!(msg.contains("also failed"), "got: {msg}");
521 assert!(
522 staging.join("fresh.bin").exists(),
523 "new artifacts must survive"
524 );
525 let old_path = staging.with_file_name({
526 let mut n = staging.file_name().unwrap().to_os_string();
527 n.push(".old");
528 n
529 });
530 assert!(
531 old_path.join("previous.bin").exists(),
532 "previous artifacts must survive (moved aside)"
533 );
534
535 fs::remove_dir_all(&root).unwrap();
536 }
537
538 #[test]
541 fn failed_swap_in_without_previous_output_keeps_staging() {
542 let root = unique_tmp_dir("swap-fresh-fail");
543 let _ = fs::remove_dir_all(&root);
544 create_dir_all(&root).unwrap();
545 let output = root.join("bins");
546 let staging = create_staging_dir(&output).unwrap();
547 write(staging.join("fresh.bin"), b"fresh").unwrap();
548
549 let err = commit_staging_dir_impl(&staging, &output, |_, _| Err(io_fail())).unwrap_err();
550
551 assert!(format!("{err:#}").contains("remain at"), "got: {err:#}");
552 assert!(
553 staging.join("fresh.bin").exists(),
554 "only copy of the artifacts must not be deleted"
555 );
556
557 fs::remove_dir_all(&root).unwrap();
558 }
559
560 #[test]
563 fn failed_move_aside_keeps_output_and_discards_staging() {
564 let root = unique_tmp_dir("swap-aside-fail");
565 let _ = fs::remove_dir_all(&root);
566 let output = root.join("bins");
567 let staging = create_staging_dir(&output).unwrap();
568 create_dir_all(&output).unwrap();
569 write(output.join("previous.bin"), b"previous").unwrap();
570 write(staging.join("fresh.bin"), b"fresh").unwrap();
571
572 let output_src = output.clone();
573 let err = commit_staging_dir_impl(&staging, &output, |src, dst| {
574 if src == output_src {
575 Err(io_fail())
576 } else {
577 fs::rename(src, dst)
578 }
579 })
580 .unwrap_err();
581
582 assert!(format!("{err:#}").contains("move previous"), "got: {err:#}");
583 assert!(output.join("previous.bin").exists());
584 assert!(!staging.exists());
585
586 fs::remove_dir_all(&root).unwrap();
587 }
588
589 #[test]
591 fn failed_generation_leaves_existing_output_untouched() {
592 let root = unique_tmp_dir("gen-fail");
593 let _ = fs::remove_dir_all(&root);
594 let output = root.join("bins");
595 create_dir_all(&output).unwrap();
596 write(output.join("existing.bin"), b"keep me").unwrap();
597
598 assert!(generate_all_circuit_binaries(&output, false, 0, None).is_err());
600 assert!(output.join("existing.bin").exists());
601
602 fs::remove_dir_all(&root).unwrap();
603 }
604
605 #[cfg(unix)]
608 #[test]
609 fn unwritable_parent_fails_before_building_and_leaves_output_untouched() {
610 use std::os::unix::fs::PermissionsExt;
611
612 let root = unique_tmp_dir("gen-parent-ro");
613 let _ = fs::remove_dir_all(&root);
614 let output = root.join("bins");
615 create_dir_all(&output).unwrap();
616 write(output.join("existing.bin"), b"keep me").unwrap();
617
618 fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap();
619 if fs::create_dir(root.join(".probe")).is_ok() {
621 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
622 fs::remove_dir_all(&root).unwrap();
623 return;
624 }
625
626 let result = generate_all_circuit_binaries(&output, false, 1, None);
627 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
628
629 let err = result.unwrap_err();
630 assert!(
631 format!("{err:#}").contains("staging directory"),
632 "got: {err:#}"
633 );
634 assert!(output.join("existing.bin").exists());
635 assert!(!output.join("common.bin").exists());
636
637 fs::remove_dir_all(&root).unwrap();
638 }
639}