logos-blockchain-circuits/docs/build-pipeline.md
2026-05-29 12:13:44 +02:00

6.2 KiB

Build Pipeline

This document walks through the CI build steps, from .circom source to the full set of release artifacts.


Overview

Step What happens
1 — Circom compilation .circom source compiled to C++
2 — Proving key generation .r1cs + .ptau.zkey and verification_key.json
3 — Patching main.cpp Fix missing return to prevent UB infinite loop
4 — The FFI layer Common files and circuit-specific FFI layered into {circuit}_cpp/
5 — Compilation and linking Compile with symbol isolation
6 — The .dat file Binary circuit data embedded in the Rust crate at compile time
7 — GMP Bundled static libgmp.a used instead of system GMP
8 — Rust build script Resolves library paths, emits Cargo link directives
9 — Rapidsnark Prover and verifier binaries built and bundled

Step 1 — Circom compilation

Produces three outputs:

  • {circuit}_cpp/ — C++ source files for the witness generator.
  • {circuit}.r1cs — the constraint system, used for proving key generation.
  • {circuit}.dat — binary circuit data.

Step 2 — Proving key generation

The .r1cs from Step 1 is combined with the Hermez Powers of Tau ceremony file to produce:

  • proving_key.zkey — Groth16 proving key, used by the prover to generate proofs.
  • verification_key.json — used to verify proofs.

Step 3 — The main.cpp return patch

Immediately after circom runs, the build patches main.cpp to insert a return 0; at the end of main().

Why: The FFI layer calls circom's main() directly, which is already technically UB in C++ (calling main directly is forbidden by the standard). On top of that, circom generates main() with no explicit return on the success path.

With -O3, the compiler treats the missing return as undefined behaviour and can optimise the entire success path into an infinite loop.


Step 4 — The FFI layer

Circom's generated C++ has no stable external API, the FFI layer adds one. It consists of two groups of files copied into {circuit}_cpp/ before compilation:

  • Common adapter files (circom_adapter, types, circom_fwd, assert.h): Bridge the circom internals to a stable C ABI, shared across all circuits.
  • Circuit-specific entry points (src/{circuit}/ffi.cpp): The public extern "C" functions that become the library's API.
src/
  types.hpp           → copied into {circuit}_cpp/
  circom_adapter.cpp  → copied into {circuit}_cpp/
  circom_adapter.hpp  → copied into {circuit}_cpp/
  circom_fwd.hpp      → copied into {circuit}_cpp/
  assert.h            → copied into {circuit}_cpp/
  {circuit}/
    ffi.cpp           → copied into {circuit}_cpp/{circuit}/
    ffi.hpp           → copied into {circuit}_cpp/{circuit}/

Step 5 — Compilation and linking

Compilation

All sources are compiled with -Dmain=circom_main (alongside standard flags), with bundled GMP headers prepended before the system include path — see Step 7.

Symbol isolation

All circuits compile the same internal functions (loadCircuit, get_size_of_witness, etc.) from the same source, but with different circuit-specific constants. When multiple circuits are linked into the same binary, the linker silently picks one definition per symbol and discards the rest, mixing constants across circuits and corrupting witness generation.

To prevent this, every internal symbol is hidden: circuit objects are merged into a single relocatable object, then all symbols except the two public entry points are demoted to local. Local symbols are invisible to the final linker, so each circuit keeps its own private copy.

fr.o is the exception: it stays global and is added to the archive separately since it doesn't vary between circuits.

See CONTRIBUTING.md § Symbol Isolation for the full explanation and implementation details.


Step 6 — The .dat file

{circuit}.dat (produced in Step 1) is embedded in the Rust crate at compile time.


Step 7 — GMP

To standardise the GMP version used by all circuits, libgmp.a is built from source as part of the CI build and placed in a lib/ directory alongside the circuit artifacts. CI sets include/link flags to point at the bundled GMP before any system path, ensuring it takes priority.


Step 8 — Rust build script

Each -sys crate delegates its build.rs to lbc_build, which resolves the library paths, links lib{circuit}.a and libgmp.a into Cargo, and re-exports the path to witness_generator.dat for compile-time embedding. See rust/README.md.


Step 9 — Rapidsnark

The prover and verifier binaries are built from the rapidsnark submodule and bundled with the release artifacts alongside the circuit libraries.


Regenerating a circuit

If you change a .circom source file:

  1. Recompile the circuit and rebuild the library.
  2. Update the proving key if the R1CS changed — a changed .circom almost always changes the R1CS, which invalidates the existing .zkey.

If you add a new public FFI entry point to src/{circuit}/ffi.hpp, update PUBLIC_SYMS in the Makefile. Any symbol not listed there will be localised and the linker will fail to find it.